Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,21 @@ jobs:
TZ=$tz node scripts/test-spaces-calc.ts
done

- name: Trail, periods and progress charts (several timezones)
# A trail row's key is a LOCAL day, so the whole bug class here is the
# one the journal rig exists for. Kiritimati (UTC+14) and Niue (UTC-11)
# bracket every hour where "which day is it" has two answers. The rest
# of the rig is behavioural: it writes rows, draws the charts, and
# asserts that a gap draws as a gap and never as a zero.
# (Bundled: it reaches store.ts, whose imports are extensionless.)
run: |
slides/node_modules/.bin/esbuild scripts/test-spaces-trail.ts --bundle --platform=node --format=esm \
--outfile="$RUNNER_TEMP/test-spaces-trail.mjs"
for tz in UTC Europe/Berlin Pacific/Kiritimati Pacific/Niue; do
echo "--- $tz"
TZ=$tz node "$RUNNER_TEMP/test-spaces-trail.mjs"
done

- name: Spaces Markdown round-trip rig
# The About dialog promises, in eight languages, that "a space is never
# a dead end … every page exports as Markdown". This is that sentence,
Expand Down
119 changes: 119 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6390,3 +6390,122 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40
mapped, 1 correctly absent, run by bento-team-slides.

Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8

---

## 2026-09-11 — The derivation rule, restated narrowly: a record is not a derivation

**Decision.** `bento/spaces` gains `doc.trail` — a map of `YYYY-MM-DD` (or
`<seriesId>/<YYYY-MM-DD>`) to the counts that were true on that day. That is a
STORED observation, and this codebase has refused stored answers everywhere
else: `calc.ts` stores `budget * 0.3 =` and never the answer, `fields.ts
viewRows` refuses to keep its own copy of the rows ("a database that keeps its
own copy of the rows is a database that disagrees with the document"), and
`model.ts tableOf` normalises at read time rather than by rewriting.

The rule those three actually follow is narrower than "everything derives", and
it is this:

> **Never store what the current document already implies. Do store an
> observation it cannot reproduce.**

Under that reading the trail is not an exception. The past is not a function of
the present state: no recomputation over today's pages answers "how many points
were open on 3 September". Four members of the same class are already in the
format — `page.created`/`page.edited`, `Comment.at`, `Page.journal` (the date a
page IS), and `doc.revisions` when it lands. Every one is a timestamped
observation nobody can recompute.

**The falsifiable half, which the charts hold to and a rig asserts.** The
chart's TODAY point is derived from live state; only strictly-earlier days are
read from the trail. Change an estimate now and today's point moves
immediately, exactly as `calc.ts` promises. The record is consulted only for
days that have closed, where there is nothing left to disagree with.

Rejected: deriving burndown from `doc.revisions` instead. Its pruning folds the
distant past first — which is exactly the resolution a chart of last sprint
needs — revisions are keyed to saves rather than days (so daily points would be
a resampling decision made at render time), folding the chain per point per
render is 90 whole-space reconstructions for a 90-day chart, and "Clear history"
is a first-class control, so the chart would silently empty when someone tidied
up.

**Pointers.** `spaces/src/trail.ts` (the shape and the rule), `spaces/src/
observe.ts` (one counting rule — the board's), `scripts/test-spaces-trail.ts`.

## 2026-09-11 — The host follows the data source: charts of pages are views, charts of the trail are blocks

**Decision.** Gantt and workload are `view` LAYOUTS (#443) and that stays right:
they read pages live, and `source`/`filter`/`sort`/`groupBy` genuinely narrow
them at read time. Burndown, burnup and cumulative flow are a NEW `chart` BLOCK
instead — `{ type:'chart', kind, period, html }`.

**Why they are different in kind.** They read `doc.trail`, whose rows are counts
written at aggregation time. You cannot retroactively filter "project = Apollo"
out of a stored count of 47, because the pages that made it are deliberately not
in the record. A view layout would therefore hand them `source` and `filter`
keys that LOOK like they narrow the chart and silently do not — the exact
failure mode this codebase keeps hitting. #443's own reasoning left this door
open: "what does want a chart block is a chart of data that is not pages."

What DOES narrow a trail chart is frozen onto its `doc.periods` entry at commit
time, where editing a view next month cannot retroactively redefine last
month's sprint. A dangling `period` renders "this chart's period is gone",
never an empty graph.

**These three draw their own SVG rather than calling `kernel/src/charts.ts`,**
and the reason is measured rather than preferred. Charts-lite interprets the
ECharts option shape but implements a subset: `renderCartesian` maps every datum
through `num(v, 0)`, so an ABSENT DAY WOULD DRAW AS ZERO — which is the one
thing these charts exist not to do; there is no `stack` support, and a CFD is
stacked bands by definition; and `stroke-dasharray` is set only to animate a
sweep, so a thinned weekly sample could not be drawn distinguishably from a
daily reading. Kernel is serialized and this is not a kernel change, so spaces
draws them, exactly as `graph.ts` draws the graph view rather than shipping d3.

**Pointers.** `spaces/src/charts.ts`, `spaces/src/periods.ts`,
`spaces/src/blocks.ts` (the registry entry).

## 2026-09-11 — One record budget, proportional: the record never outweighs what it is a record of

**Decision.** `doc.trail` and (when it lands) `doc.revisions` share ONE ceiling,
computed from the document's own content:

```
recordBudget = clamp(25% of content bytes, 64 KB, 256 KB)
```

where content is `title`/`home`/`theme`/`pages` — the same four fields history
covers, and deliberately NOT `assets`: one embedded photograph is bigger than
any ceiling here, and a space with a picture in it has not thereby earned more
room to record cadence in.

**Why not two fixed constants.** A 32 KB trail budget beside a 128 KB history
budget is 160 KB of record: absurd in a 37 KB space and unremarkable in a 2 MB
one. Two absolute ceilings do not scale with the document they are attached to.

Each tiers independently within the shared ceiling, and **the trail thins
first** — its allowance is the budget less what history currently holds. A trail
can be thinned without losing a day's meaning (a weekly sample is still an
observation somebody made); folding two revisions together loses the ability to
restore to the point between them. On a build where only one of the two exists,
that one simply has the whole budget, and history's bytes are read generically
off `doc.revisions` so neither side needs to know about the other.

Thinning SELECTS and never averages — an averaged row is a number nobody
observed, the same lie as interpolating a gap, written to disk — and a row that
survives a thinning carries `s`, the span it stands for, reaching back only as
far as the earliest row it replaced. A GAP IS DRAWN AS A GAP: no interpolation,
no carry-forward, no zero, and absent must look different from zero (a day with
nothing open is a point at zero; a day nobody opened the file is nothing at
all).

**Two consequences worth stating.** The trail is stripped from published copies
and page extracts — cadence is what it discloses, adding an opt-in later is
safe and un-leaking a sent file is impossible. And the trail is excluded from
undo (`store.ts snapshot`), because a row is an observation rather than an
editing step; `doc.periods` is NOT excluded, because starting a period is
something a person did.

**Pointers.** `spaces/src/trail.ts` (`recordBudget`, `pruneTrail`, `slots`),
`scripts/test-spaces-trail.ts` (the tiering, the gaps, the sabotages).
43 changes: 43 additions & 0 deletions docs/spaces-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ unique ids the first time.
| `link` | `url`, `title`, `desc`, `site`, `icon`, `image`, `html` | a card linking OUT of the space — see **Link cards** |
| `prop` | `key`, `value`, `html` | one field value — see **The issue tracker** |
| `view` | `layout`, `groupBy`, `html` | a board or list of this space's issues |
| `chart` | `kind` (`burndown`/`burnup`/`cfd`), `period`, `html` | a progress chart over `doc.trail` — see **Progress charts** |

`type` is a **string**, not a closed set: an unknown type survives a round trip
and renders its `html` as a fallback. Properties are **flat on the block** —
Expand Down Expand Up @@ -269,6 +270,48 @@ view is laid out wide.
**Not in this format, deliberately**: teams, per-user permissions,
notifications, automation. The file is the team boundary and the capability.

### Progress charts

A `chart` block draws **burndown**, **burnup** or **cumulative flow** from
`doc.trail` — a record the file keeps of itself, one row per local day:

```jsonc
{
"trail": { // document-level, optional
"2026-09-08": {
"n": { "todo": 3, "doing": 2, "done": 1 }, // issues per STATUS OPTION id
"e": { "todo": 11, "doing": 8, "done": 4 }, // summed estimate, absent if nothing is estimated
"x": 2 // issues counted that carried no estimate
}
},
"periods": { // the windows charts are drawn over
"pd-1": { "label": "Sprint 12", "from": "2026-09-01", "to": "2026-09-20",
"base": { "at": "2026-09-01", "n": 6, "e": 23 } }
}
}
```

Three things to know before you touch either of them.

**Do not write past rows.** The app writes TODAY's key and only today's; a row,
once written, is immutable for the rest of the file's life. Rewriting yesterday
from today's numbers is the one thing the whole design exists to prevent.

**A missing key is a GAP and is drawn as one** — a broken line and a hatched
band labelled "not recorded". Do not fill gaps in, and never carry a value
forward: a carried value looks exactly like data. A row carrying `s` stands for
that many days ending on its own key and draws dashed, so a weekly sample is
never mistaken for a daily reading.

**A row holds counts and nothing else.** No page ids, no assignee breakdown, no
per-issue anything — that is a budget rule and a privacy rule at once, and it
is not an oversight to be helpfully corrected. What a trail discloses is the
team's cadence, which is why a reading copy and a page extract carry none of it.

Both keys are absent until something is recorded, and a cleared space deletes
the key rather than storing `{}`. A `chart` block whose `period` names nothing
renders "this chart's period is gone" rather than an empty graph.

## What makes a space good rather than merely correct

| When the material is… | Reach for | Why |
Expand Down
Loading
Loading