Superseded in two statements by ADR 0024, ruled and built 2026-09-13 (#331). A live row answers one tree's table row
parentId→parent()(below) and its sentence "entry.read('parentId')answersparent()?.id" no longer hold, and neither does one row, one tree, whichever door you ask through:read('parentId')now answers the stored value, like every other key, on every door. The tree kept its own by-key door —hierarchyParentId, a new core Field — rather thanparentIdcontinuing to answer two different questions depending which door asked. Do not rewrite the body. Everything else in this ADR stands,read()as the one by-key value door included.
Vocabulary note, added 2026-09-17 (#421). This record predates the
Item→Barrename (ADR 0026). Read everyItembelow asBar. Do not rewrite the body.
Vocabulary note, added 2026-09-21. This record predates the
distribute→writeToChildrenrename. Read everydistributebelow aswriteToChildren, andFieldDistributorasFieldWriteToChildren. Do not rewrite the body. The key itself left the library in #470: a rolling-up parent's cell is refused, never written by a declared policy.
The Entry answers questions about itself
This is the first of four ADRs that give one row one object. 0018 makes the variant a rule. 0019 gives a plugin one install site. 0020 lets a plugin say what the tree is. This one comes first, because all three of the others read questions off the row.
Context
One row has three names, and it lives in three places.
Entryis the stored values. It holds ids, dates, Segments andprops(model/entry.ts).- Tree shape and Field values are questions on the
Dataset—entries.childrenOf(id),entries.fieldValue(id, key)(model/dataset.ts). - The variant is a registration on the
Gantt—registerLookClaim,registerItemProducer(view/plugin-ports.ts).
Nothing joins the three. So every seam that needs two of them re-derives the join. Core does this as often as a plugin does, and so does a consumer. Seven sites show it, all read at HEAD on 2026-09-11. A line number is a hint — open the file.
| Site | What it does | What it shows |
|---|---|---|
harness/planner.ts:70 | const isPhase = dataset.entries.childrenOf(entry.id).length > 0; | The consumer-side site, and the strongest. A cell renderer holds an Entry and must reach back to the Dataset to learn whether its own row has children. A consumer has no private fast path to hide behind. |
src/model/field.ts:303,325,333 | Aggregator = (children, parent, ctx) => … (:333), and FieldDistributor (:325) and RollUpContext.values(children) (:303) take the same pair | children is always the children of parent. It rides beside parent because parent cannot answer. This is the surface a consumer writes an Aggregator against. |
src/layout/items/produce-items.ts:235,272 | resolveItems(entry, registry, hasChildren: boolean); produceItemsForRow(…, hasChildren: (id) => boolean) | Read it aloud: "resolve items, entry, registry, has children." A fact about one row, threaded as a positional argument through a call about a frame. |
src/view/capability.ts:92-118 | CapabilityInputs takes five injected functions: hasChildren, descendantsOf, fieldFor, lookOf, registeredDefaultsFor | Four of the five are questions about one row, and core built them by hand out of side channels. fieldFor is the fifth and is not one — it is dataset.field(key), and it stays. |
src/view/gantt-shell.ts:1141,1148 | dataset.entries.childrenOf(entry.id).length > 0 | Core re-derives a boolean the store already holds. |
src/data/entry-store.ts:248 | #hasChildren(parent), on a fast path the stagedParents filter protects | The boolean exists, is cheap, and is private. |
src/layout/items/produce-items.ts:60 | type LookClaim = (entry: Entry) => boolean | A claim receives the stored values alone. It cannot ask anything, so both shipped examples claim on an owned-id Set instead. |
#214 is the same hole under another name: a compute Field cannot depend on the tree, because FieldContext cannot reach a second Entry.
Decision
A row has two types, and one line divides them.
StoredEntryis the stored values at one point in time. The edit pipeline carries it, and every derived type reads off it.Entryanswers questions about the row now. Every read seam receives it.
A seam that asks a question about now receives an
Entry. A seam that describes a change carriesStoredEntryvalues.
The first draft said the Entry simply was Entry. Four findings killed that, and each one is a real type or a real hot path. They are in Why StoredEntry stays, below.
The Entry
interface Entry<TProps = Record<string, unknown>> {
readonly id: EntryId;
readonly name: string;
readonly start?: Instant;
readonly end?: Instant;
readonly segments: readonly Segment[];
/** The one by-key value door: a core key, a `props` key, or a `compute` Field. Every answer is
* live, `'parentId'` included — see *A live row answers one tree*. */
read<K extends FieldKey>(field: K): FieldValue<TProps, K> | undefined;
readonly hasChildren: boolean; // free: a cached index read, no allocation
children(): readonly Entry<TProps>[];
parent(): Entry<TProps> | undefined;
descendants(): readonly Entry<TProps>[];
readonly depth: number;
/** The loose input shape, and exactly what `entries.add()` takes. It is how a row is copied. */
toInput(): EntryInput<TProps>;
}
The call sites this publishes:
entry.hasChildren // the boolean core and the harness both write by hand today
entry.read('cost') // one door, not dataset.entries.fieldValue(id, 'cost')
entry.children().every((c) => …) // a rule may now ask about the tree (#214)
entries.add({ ...entry.toInput(), id: 'copy-1' }) // duplicate this row
toInput() earns its place on that last line, and that is the whole of its job. A copy needs the values a copy stores — props included — and read() answers a Field, not the input shape. So the one member that hands back stored values names what it is for: input. It is not a second read door, and nothing in a renderer, a rule or a capability calls it.
Which seam gets which
Receives an Entry | Carries a StoredEntry |
|---|---|
the variant rule and the Item producer (LookClaim, ItemProducer) | ProposedEdit / ProposedEdits |
capability resolution and every Interactions predicate | EditRequest.entries — the pre-transaction snapshot (D-S5-45) |
| every renderer context that names an entry | EditRequest.entryAfterEdits(id) — the post-body state (D-S5-45) |
a command's when and run | entryAfterEdit and its five callers (field-access.ts:242) |
entries.get, entries.all, the value entries.add returns — all hands back live rows, and which rows it hands back is still committed-only (rule 2) | a ChangeSet's {from, to} values |
CoreFieldKey and CoreFieldValues, which derive from StoredEntry | |
EntryInput, which is what a consumer writes | |
Aggregator, FieldDistributor, RollUpContext — corrected 2026-09-11, see below | |
a compute Field's entry — same reason. It asks the pass for the rest: ctx.read(key), ctx.children() |
The Rollup reads a row the store does not hold. The first draft put the Aggregator on the left. It is wrong, and rollup.ts:262-279 is why: each child there is effectiveEntry(childId, entries, merged, computed) — the store, plus this transaction's merged edits, plus the values this same bottom-up pass has already computed for that child. computed never reaches the store. A live Entry answers the store's overlay, so a child read through one loses every value the pass just produced, and bottom-up rollup stops working. diffEdit (change-set.ts:61-72) reads a second such row — entryAfterEdit(current, edit), a post-edit row no commit has taken. Both seams stay on stored values, and both read the tree through ctx.children().
The names
Ruled 2026-09-11, after the five checks in the naming skill.
Entry names the live one. The bare word goes to the surface most people read: every variant rule, item producer, Aggregator, capability rule and renderer context. It is what makes the author's own sentence compile — (entry) => entry.hasChildren.
StoredEntry names the values. "Stored" already has exactly one meaning in this codebase — it has a home in storage — in CLAUDE.md and in data/fields/field-access.ts:1-3's "storage-shaped edit". It never means "already committed", which matters because entryAfterEdits(id) answers with state no commit has taken.
entries: ReadonlyMap<EntryId, StoredEntry>;
entryAfterEdits(id: EntryId): StoredEntry | undefined;
type CoreFieldKey = keyof Omit<StoredEntry, 'id' | 'props'>;
That last line is why the name earns its place. CoreFieldKey is the question "which keys does storage own", and P1 below is that question silently harvesting live members instead.
Three names lost. EntrySnapshot fails check 4: CONTEXT.md:23 gives "Snapshot" to the committed, cached array all returns. EntryRecord fails check 1: CONTEXT.md:37 lists "record" and "row" under Avoid for this concept, which is why this ADR says stored values and never record. EntryHandle fails check 4: CONTEXT.md:127 gives "handle" to a held event registration.
CONTEXT.md keeps one Entry entry. StoredEntry is named inside it, as what an Entry's stored values are called — not as a second concept.
Five rules
-
model/declares both types.data/builds theEntry. The interfaces are types, somodel/keeps zero runtime (plans/01§1.1). The factory sits beside the store and the indexes it reads. -
One
Entryper id, and every read is live. TheEntryallocates nothing per frame and keeps a stable identity inside aSet. Live means whatchildrenOfanswers today: the committed index, overlaid with the open write set (entry-store.ts:214-218). It does not meanall, which is committed-only (D-S2-21,:167).Two questions hide in one word, and
allanswers them differently. Which rows exist is the collection's question, andallanswers it as of the last commit — the array is acomputedbound to#revision, andScaleBinding's reference comparison rests on that (D-S1.5-4,entry-store.ts:150-153). What a row is worth is the row's question, and everyEntryanswers it now. So inside an open transactionalldoes not grow, and each row in it already reads the write set.has,getandsizeare the live membership doors, and all three follow the write set today —all.lengthnever did.A review asked for
all: readonly StoredEntry[]instead, and it is refused. It reads as the cleaner snapshot, and it costs the thing this ADR is for:layout/receives that array (gantt-shell.ts:725), andentries-source.ts:55then rebuilds the whole tree out ofparentIdbecause stored values are all it holds. That is the re-derivation 0020 cannot survive. The stored values stay reachable where they are needed and nowhere else:entry.toInput()copies a row, and the edit pipeline carriesStoredEntryalready.entries.storedValuesis the third door, added during the build (J20): the committed rows as aReadonlyMap<EntryId, StoredEntry>, the store's own index handed out read-only. It is not this refusal reopened — the refused shape was the row listlayout/receives, which must stay live orentries-source.tsrebuilds the tree out ofparentId.storedValuesanswers one question (EditRequest.entries, committed-only by D-S5-45) andlayout/never calls it. -
No derived type reads
keyoftheEntry.CoreFieldKey,CoreFieldValuesandProposedEditall derive fromStoredEntry, and they stay that way. -
A member that does no work is a property. A member that computes, walks or allocates carries parentheses.
id,name,start,end,segmentsanddepthare properties — each one hands back what the row already holds.hasChildrenis a property too: it reads the cached#byParentindex and answers a boolean, so it allocates nothing.children(),parent()anddescendants()carry parentheses, because each one computes, walks or allocates.This rule was stated wrongly in the first draft, as "a getter answers one value; anything that returns a collection is a method." Its own interface breaks that twice:
parent()answers one value and carries parentheses, andsegmentsis a collection and is a property. The principle was always the cost, never the arity. -
read()is the one value door, soentry.propsleaves the read surface. A withdrawn draft pickedreadas the name first, for a by-key door besidefieldValue(the gap at 0014). That draft was withdrawn for a good reason, and this ADR is not a revival of it. It kept the read on the collection, soreadwould have been a second by-key door and the caller would still name an id it already holds. This ADR puts the read on the object that holds the value.entry.read('cost')reads true in English, and one surface publishes it, not two.Three doors retire, and all three are decided (2026-09-11, author's ruling).
entries.fieldValue(id, key),FieldContext.read(entry, key)andFieldContext.durationOf(entry)all answer "what is this Field worth on this row". All three retire intoentry.read(key). Three seams read a row the store does not hold, and they read the tree throughctx.children()instead — see What a hypothetical row reads with.Door at HEAD Where Fate entries.fieldValue(id, key)model/dataset.ts:28— 77 refs in 14 filesdeleted — decided FieldContext.read(entry, key)model/field.ts:283,data/fields/field-access.ts:126— 11ctx.readcall sites, plus 3 declaration and implementation sitesthe entryargument goes — ruled 2026-09-11. The question moves onto the pass asComputeContext.read(key)FieldContext.durationOf(entry)model/field.ts:286,data/fields/field-access.ts:133— 13 refs in 9 filesthe entryargument goes — ruled 2026-09-11. The question is a Field read:entry.read('duration')on a row,ctx.read('duration')inside a pass.Neither door survives on a row a caller names. That is what "retire" means here, and it is what makes
entry.read(key)the one by-key door on a row. A pass is not a row: it is computing exactly one, so it asks with no argument at all.
The Entry reads. The store writes. There is no entry.update(). dataset.entries.update(id, edit) stays the one write door, and ADR 0015 keeps everything it decided.
Every core Field reaches the row once. Core declares six Fields (data/fields/core-fields.ts:65-105), and the live Entry gives each stored one a member:
| Core Field | On the Entry |
|---|---|
name, start, end, segments | the property of the same name |
parentId | parent() — one fact, one surface |
read(key) answers 'duration' exactly as it answers 'start'. duration is a computed Field, so it has only the by-key door, like every computed Field a consumer declares.
The Entry answers data questions only. The variant is per Gantt, because two Gantts on one Dataset may install different variants. So entry.variant is not on the Entry. See 0018.
A live row answers one tree
StoredEntry publishes parentId. The Entry publishes parent(). One fact reaches each surface once. EntryInput.parentId stays, because it is input.
entry.read('parentId') answers parent()?.id — ruled 2026-09-11, on a review finding. parentId is a core Field, so read must answer it: read is the by-key door, and a Grid column on parentId goes through it like every other column. A live row that answered the stored field there would disagree with its own parent() the moment 0020 lands, and a WBS column would print a stale id beside live indentation. One row, one tree, whichever door you ask through.
The write side does not move: update(id, { parentId }) writes the stored field, and StoredEntry.parentId is what it wrote. Under a plugin-owned hierarchy those two may differ, and that is the plugin taking the tree over on purpose (0020, What core keeps).
Prefer parent() anyway. It answers with the row, not with an id, so the next call is a read and not a lookup. read('parentId') exists for the generic caller — a column, a serializer, a values() fold — that holds a key and not a member name.
Why StoredEntry stays
Four findings, each verified at HEAD on 2026-09-11. Any one of them alone decides the two-type split.
P1 — the core Field key list derives from StoredEntry. model/field.ts:12 is export type CoreFieldKey = keyof Omit<Entry, 'id' | 'props'>;, and :21 builds CoreFieldValues the same way. Put seven Entry members on the type those read, and all seven become core Field keys. gantt.gridColumns = ['children'] would then typecheck, and isCoreFieldKey (data/fields/core-fields.ts:115) would answer false at runtime and throw UnknownFieldError on a key the compiler just offered.
P3 — so does the edit shape. model/entry.ts:179 is & Partial<Omit<Entry, 'id' | 'start' | 'end' | 'props'>>. Every Entry member would join ProposedEdit as an optional member, and a plugin author reads ProposedEdit off EditRequest.proposed.
P2 — core spreads StoredEntry on its hot path. data/fields/field-access.ts:242 is const next: Entry = { ...entry };, and :257 then assigns next.props. Five callers reach it (entry-store.ts:180, entry-tree.ts:17,36,53, rollup.ts:198, change-set.ts:61). A spread copies own enumerable properties only. An Entry would lose its methods there and still typecheck, and the assignment would refuse against a getter in strict mode. Under this ADR nothing changes at that line: it takes a StoredEntry and returns one.
P4 — two Entry positions are deliberately different. model/entry.ts:204 documents EditRequest.entries as the state before this transaction's edits. :216 documents entryAfterEdits(id) as the state after its body. D-S5-45 is why both exist. A live Entry answers one question, so it would collapse the pair and hand every cascade a delta of zero. Both stay stored values.
The seam with layout/
The Entry must not couple layout/ to data/ (the author's constraint, 2026-09-11). It does not, and one existing rule proves it.
model/declares the interface. It is a type, somodel/keeps zero runtime.data/builds it, beside the store and the indexes it reads.layout/names the type and never the factory..dependency-cruiser.cjs:63already forbids everything buttimeandmodeltolayout/. That rule does not change, and it is what holds the seam.
So the import graph is the one that ships today — layout/ → model/. What changes is the member list on a type layout/ already imports.
One hazard is real. An Entry lets a layout pass pull from the store lazily, and a per-row descendants() call inside a frame would walk the tree once per row. Rule 4 is the guard.
Consequences
Core stops re-deriving. Three of CapabilityInputs' five injected functions collapse to the entry argument — hasChildren and descendantsOf here, lookOf in 0018. fieldFor stays. It is dataset.field(key), a Field-registry lookup (view/capability.ts:202), and a row holds no registry — it is not a question about the row at all. gantt-shell.ts:1141 and :1148 go, and :1142 loses its inner childrenOf with descendantsOf. resolveItems' positional hasChildren goes. #hasChildren stays private: layout/ may not import data/, so nothing outside the store gains a caller, and a public one would be a second door onto entry.hasChildren.
The Aggregator surface loses an argument, and keeps its own child list. Aggregator = (parent, ctx) => … and FieldDistributor = (value, parent, ctx) => …. The children argument does not become parent.children(): the paragraph above says why that answers the wrong state. It moves onto the context, bound to the list the Rollup built — ctx.children(), with ctx.values(key?) and ctx.numericValues(key?) reading it and taking no child list (the key defaults to ctx.field). The gain the evidence table asked for is the same: one list, passed once, never beside a parent that cannot answer for it. RollUpContext keeps its own reads. rollup.ts:23-26 holds two edit sets for two questions — body and merged — and refuted item 8 in field-redesign/shared/refuted.md records that the split is deliberate. Do not delete ctx.values() on the strength of this paragraph.
The public surface gets smaller, not larger. EntryStoreView.childrenOf and EntryStoreView.fieldValue become Entry members. The cost is entries.get(id)?.children() ?? [] where a call site used childrenOf(id). Ruled 2026-09-11: they go.
P7 — this ADR deletes fieldValue, which is what HEAD ships (model/dataset.ts:28). The only other ADR that proposed to rename this door was withdrawn on 2026-09-11 and deleted (the gap at 0014), so nothing else renames it and no build has to coordinate with one. This ADR owns the change outright: 77 references in 14 files, and one rename happens, not two.
A removed id carries no flag. An Entry outlives its StoredEntry and keeps the last values it read. Existence has one door already — entries.has(id), and entries.get(id) answering undefined. A removed boolean would be a second door onto the same fact, and it would invite an if (entry.removed) branch at every reader. Nothing in core needs one: the edit pipeline carries StoredEntry values, so undo and replay never meet an Entry.
#214 closes. A compute Field walks its own children through ctx.children(). It reads them off the context and not off its entry argument, because readField calls a compute with rows the store does not hold.
Three sites outside the store stop reading .parentId here, and a fourth waits for 0020. layout/rows/entries-source.ts:16, layout/frame-memory.ts:77 and view/tree-collapse.ts:111,153 each ask what the tree is by reading the stored field. They ask entry.parent(), entry.children() or entry.hasChildren instead. data/rollup.ts:46,52 does not move with them. collectTouchedIds reads the entries map it was handed — stored values, not the store's now — to find the former parent of a moved row, and :51 asked what the edit wrote, through a 'parentId' in edit check. That check is gone (J53, 0020): a plugin source may answer out of a props key, so a row can move with no parentId write to spot. collectTouchedIds now asks the source for every edit instead. Ask a live parent() there and the pass misses the former parent and skips a Rollup after a move. That invalidation belongs with the hierarchy source, so 0020 takes it. This is the part of the work 0020 cannot land without: a site left reading the field disagrees with the library the moment a plugin owns the hierarchy. data/entry-reader.ts:229,567 name parentId too, but those two write the stored value and stay as they are.
spansTime(entry) stays a free function. It narrows the type, and a getter cannot. It reads start and end only, so it serves both types.
Why the three doors go — ruled 2026-09-11
Both doors leave the public surface. One caller reaches a live Entry, and three cannot. Verified at HEAD, and the first draft of this table claimed all five could.
| Caller | Today | After |
|---|---|---|
extensions/features/inline-editing.ts:109-126 | hand-builds a whole FieldContext | the function goes — it reads the store's now, through entries.fieldValue |
data/fields/core-fields.ts:106 — the duration Field's own compute | compute: (entry, ctx) => ctx.durationOf(entry) | compute: (entry) => spansTime(entry) ? { value: diffMs(entry.end, entry.start), unit: 'millisecond' } : undefined — the row's own dates through time/; it asks the pass nothing. readField calls a compute with a post-edit row (change-set.ts:61) and with an effective child (rollup.ts:262), so neither argument is a live Entry. |
data/fields/aggregators.ts:14,51 — weightedMeanByDuration | ctx.durationOf(entry), ctx.read(child, ctx.field) | ctx.values() beside ctx.values('duration'). Its children are effective rows, computed values included. |
data/change-set.ts:72 | ctx.read(current, key) and ctx.read(next, key) in the same expression | readField directly. diffEdit sits in data/, and next = entryAfterEdit(current, edit) is a row no commit has taken. |
The first row is why this is worth doing, and it is not blocked. fieldContextFor() exists only because a parseValue needs a FieldContext and extensions/ cannot reach data/. Its two members are read and durationOf. Both are Field reads, and entry.read(key) on the row answers both. Hand parseValue that row, and there is nothing left to build — FieldContext is { timeZone }, and dataset.timeZone is public. That deletes the shim, the wrong 'day' unit, and the 'day'-into-MS.DAY bug below.
The other three read a row the store does not hold, so entry.read(key) cannot serve them. The next section is what they read with. It was ruled on 2026-09-11, and it does not reopen this one: both doors leave the public surface either way.
What a hypothetical row reads with — ruled 2026-09-11
The read binds to the pass, and the pass carries its own row. Every question a compute Field or an Aggregator asks is about the one row it is computing, so nothing on the context takes an entry argument. Amended 2026-09-21: a structural question — does a row have children, what are its children, descendants or leaves — is the same fact at every depth, so four members now take the row they ask about. read and hierarchyParentId() are unchanged by that amendment: a value question stays bound to the pass's own row. See the amendment.
/** Ambient. One per Dataset, reused by every read. */
interface FieldContext {
readonly timeZone: string;
}
/** What a `compute` Field runs inside. Built per pass, bound to the row being computed. */
interface ComputeContext extends FieldContext {
/** Another Field on this same row — a core key, `duration`, or another Field's `compute`. */
read<K extends FieldKey>(key: K): CoreFieldValue<K> | undefined;
/** One step down from any row this pass hands you. Amended 2026-09-21 (#466) — it took no
* argument when this ADR was written, and structure now goes down. */
children(row: StoredEntry): readonly StoredEntry[];
/** All the way down, never `row` itself. */
descendants(row: StoredEntry): readonly StoredEntry[];
/** The bottom rows of `row`'s subtree, `row` included when `row` is a leaf. */
leaves(row: StoredEntry): readonly StoredEntry[];
/** True when `row` derives (ADR 0013). */
hasChildren(row: StoredEntry): boolean;
}
interface RollUpContext extends ComputeContext {
readonly field: FieldKey;
values(key?: FieldKey): readonly unknown[]; // `ctx.field` by default
numericValues(key?: FieldKey): readonly number[];
}
/** Ambient plus this Gantt's locale. Still built once at column-resolve time (D-S4-13). */
interface FormatContext extends FieldContext {
readonly locale: Intl.LocalesArgument;
}
Three contexts, because there are three lifetimes, and a first draft of this section had two. That draft put children() on FieldContext itself. FormatContext extends FieldContext and is built once per column resolve and reused for every cell (model/field.ts, D-S4-13), so a per-row member on that type either answers the wrong row or forces a context rebuild on the paint path. Splitting says which members are per-Dataset and which are per-pass, and the type now states the lifetime it has.
A compute Field keeps its by-key read, and this is what the review caught. Take ctx.read away outright and a compute Field can reach entry.props and the core dates and nothing else: not duration, not another compute Field, not a Field a plugin declared. src/data/computed-cache.test.ts:30 is a live one that reads a sibling Field today, and the comment at model/field.ts:224-226 publishes the promise. Worse, the author who wants a duration writes the millisecond arithmetic by hand, which CLAUDE.md's time rule forbids outside time/. So both questions survive; only the entry argument goes.
One name serves both callers. An Aggregator and a compute Field both say ctx.children(), and both mean the pass's children. RollUpContext extends ComputeContext, so there is one declaration.
Where the pass binds it. readField is the one function that holds the row and the registry together, so it binds the context there, and createFieldContext keeps the ambient half alone. A stored-Field read builds nothing: only a compute arm and a Rollup pass ever receive a ComputeContext, and data/computed-cache.ts already memoizes what a compute produced per revision. The cost that must not appear is a context per cell on the paint path — formatValue takes FormatContext plus the entry it already receives, and neither changes here.
parseValue receives the ambient FieldContext and the row it is parsing into — parseValue?(text, ctx: FieldContext, entry: Entry), the same shape formatValue already has. A date parse needs the zone, which is ambient; a parse that needs a sibling value reads it off the live entry. That is what makes fieldContextFor() (extensions/features/inline-editing.ts:109-126) deletable rather than merely smaller: extensions/ holds the Entry already, and { timeZone: dataset.timeZone } is public, so nothing in extensions/ has to reach into data/ to build one.
A compute Field keeps two arguments — compute: (entry, ctx) => …, and entry is a StoredEntry. This is what closes #214: a computed value may depend on the children, and it reads them off the context rather than off a row that cannot answer.
Nothing needs a per-child door. weightedMeanByDuration reads ctx.values() beside ctx.values('duration'). diffEdit calls readField directly — it sits in data/ and already imports entryAfterEdit from that same file.
Two shapes were refused.
- An
Entry-shaped view over the hypothetical row. It saves the promised call site, and it costs more than it saves: two things implementEntry, a reader cannot tell which one they hold, andentry.parent()walks out of the pass into the store and mixes two states with no warning. - No children at all. A
computeField stays a one-row function, and #214 stays open. That gives up the reason this ADR was written. Amended 2026-09-21: #214 closes withctx.children(row), not with a shape refused here — the walk stays on the pass, off any row it hands out, and never on thecomputeField's ownentryargument.
The duration Field computes from its own row. Its compute reads entry.start and entry.end and calls diffMs, the way a consumer's computed Field does. It asks the pass nothing, so it never reads itself back through the registry. Every reader asks by key: entry.read('duration') on a live row, and ctx.read('duration') or ctx.values('duration') inside a pass.
This closes #274, including the half nobody was tracking. The issue says Duration.unit is never read, and that is literally true:
formatDuration(core-fields.ts:52) computesduration.value / MS.DAY. It assumes millisecond and never checksunit.compareDuration(:59) computesa.value - b.value. It compares raw numbers across units.inline-editing.ts:118producesunit: 'day'.
A 'day' duration reaching formatDuration divides days by 86,400,000. One producer with one unit makes both functions correct by construction, instead of correct by luck.
One stale comment goes with the shim. inline-editing.ts:110-111 says a segmented Entry's true duration is "layout/'s own durationOf, which extensions/ cannot reach." There is no durationOf in layout/. Duration is end - start everywhere, gaps included.
Closed here, and one question deferred
Does a parent's duration count the gaps between its children? Yes. The duration Field is the row's own span on every row. A parent's start and end roll up, so the gaps count. A consumer who wants another number declares their own computed Field.
TProps at the renderer seams — deferred to #284, after Build 4 (2026-09-11, author's ruling). ColumnCellRendererContext.entry?: Entry and fieldValue: unknown carry no TProps today (model/field.ts:67,73), which is why harness/planner.ts:69,85,174 all cast. A typed entry.read('cost') needs a TProps the Entry carries through those same seams. The casts sit in the harness, so under CLAUDE.md's stop rule they are evidence of an API gap, not a harness problem. No build in this redesign waits on it, and no build tidies the evidence away.
What the gap costs today, measured 2026-09-11. harness/planner.ts pays it six times: entry.props as PlannerEntryProps at :69, :85 and :174, and fieldValue as Instant at :104, :114 and :116. Every one is a consumer restating a type the library already knows.
The shape of the answer, if the author takes it. Entry<TProps> already carries TProps, so the work is to thread it through the two renderer contexts that name an entry — ColumnCellRendererContext and the Gantt-wide CellRendererContext — and to type read<K> off it. fieldValue: unknown then goes: entry.read(key) answers FieldValue<TProps, K> | undefined and the six casts go with it.
Amendment, 2026-09-21 — a pass answers about any row it hands you
Why this amendment exists. ComputeContext.children() answered for the bound row only. A
writeToChildren that must weigh every child at once, and a compute Field that must total its own
subtree, both needed to ask the same structural question about a row the pass merely hands out —
not the row it is computing. #466 closes that.
Two rules
- Each surface keeps the door it already has. A pass hands out rows, so a pass takes a row:
ctx.children(row),ctx.descendants(row),ctx.leaves(row),ctx.hasChildren(row). AnEditRequestalready answers by id (entryAfterEdits(id)), so it takes an id:request.hasChildren(id),request.writeTarget(id, field). - Structure goes down; values stay bound. Structure — whether a row has children, what its
children, descendants or leaves are — is the same fact at every depth, so a pass answers it for
any row it hands out. A value is not: a bottom-up pass has written only what it has reached, so
read(key)keeps answering the bound row and nothing else. No question on this surface gains a sibling name —ctx.children()plus a hypotheticalctx.childrenOf(row)would be two names for one question, the mistake bug #7 already records.
Why the entry argument comes back, for structure alone. What a hypothetical row reads with
(above) took the entry argument off read on purpose: it answers the pass's own
bound row, and a second row argument there would let a caller ask for a value the pass has not
written yet, order-dependent and silent about it. Structure carries no such trap: children(row),
descendants(row), leaves(row) and hasChildren(row) all read off the index the pass built once
for the whole tree (rollup.ts's byParent, mirrored by EntryStore#hasChildren), so asking about a
row other than the one being computed returns the same answer regardless of pass order. The argument
returns because the question it answers cannot go stale mid-pass; read stays bound, because its
question can.
A third sentence on rule 4. ADR 0017's rule 4 — a member that does no work is a property, a
member that computes, walks or allocates carries parentheses — was written for Entry, where each
member's own arity states its own cost. On the pass surface every member takes a row, so the
parentheses say nothing about cost by themselves: hasChildren(row) answers through whichever of
its three bindings is live (src/model/field.ts's own doc states the three), and leaves(row) is
always a subtree walk — different members, same parentheses. The rule keeps its full force on
Entry; on the pass surface, each member's own doc block states its cost instead — the parentheses
answer "does this take a row", not "how much does it cost".
The surfaces
interface ComputeContext extends FieldContext {
read<K extends FieldKey>(key: K): CoreFieldValue<K> | undefined; // unchanged — the bound row
hierarchyParentId(): EntryId | undefined; // unchanged — the bound row
children(row: StoredEntry): readonly StoredEntry[]; // one level down from `row`
descendants(row: StoredEntry): readonly StoredEntry[]; // all the way down; never includes `row`
leaves(row: StoredEntry): readonly StoredEntry[]; // the subtree's bottom rows; includes `row` when `row` is a leaf
hasChildren(row: StoredEntry): boolean; // cost follows the binding — see field.ts
}
interface EditRequest {
// … entries, proposed, entryAfterEdits, addedEntryIds, removedEntryIds
hasChildren(id: EntryId | string): boolean;
writeTarget(id: EntryId | string, field: FieldKey): WriteTarget;
}
RollUpContext adds nothing structural — field, values() and numericValues()
stay bound to the parent this pass is rolling up.
leaves(row) ships beside descendants(row), and is not built from it. A descendants(row)
call with a !hasChildren(row) filter beside it answers the same question at the cost of a second
walk and a second name at every call site — an API gap this amendment closes, not a shape to leave
for a consumer to re-derive. leaves(row) includes row itself when row is a leaf;
descendants(row) never includes row. That asymmetry is deliberate: descendants names a
relationship to a row, so the row is not its own descendant, while leaves names the bottom rows
of a subtree, and a subtree of one leaf has one leaf.
What a hypothetical row reads with (above) is otherwise unchanged: ComputeContext.children()'s
signature is the one member this amendment widens; descendants, leaves and hasChildren are new
members beside it, and read and hierarchyParentId() keep taking no argument at all.
Why it is still open. model/field.ts declares ColumnCellRendererContext and model/ may not import layout/ (model-is-leaf). A generic on a model/ type is free, but every seam that builds one of these contexts has to pass the argument, and layout/renderer.ts builds the Gantt-wide one. That is a reach across three layers on a hot path, and it earns its own decision rather than a paragraph here. Build 1 does not close it. Build 1 files it as a Q entry and leaves the six casts in place, so the evidence stays visible.
Appendix — the questions this redesign closed (Q1–Q9)
These entries were plans/row-redesign/BUILD-LOG.md. That log is deleted; the calls this ADR
cites live here, so the citation resolves inside the record that depends on it.
| What it settled | Ruling | |
|---|---|---|
Q1 | do FieldContext.read and FieldContext.durationOf retire beside entries.fieldValue? | Raised 2026-09-11, in the plan review. Build 1. CLOSED the same day — the author ruled that all three doors go. The evidence and the caller-by-caller check are in ADR 0017, Why the three doors go. #274 closes with them, including the Duration.unit half. The text below is the question as it stood. |
Q2 | do the renderer contexts become generic over TProps? — DEFERRED to #284 | Raised in ADR 0017's own open:. Sharpened 2026-09-11. Build 1. Answered the same day, and deferred to #284, after Build 4. No build in this redesign waits on it. |
Q3 | what is the hierarchy seam called? | Raised in ADR 0020's own open:. Build 4. RULED 2026-09-11: the seam is the hierarchy source. |
Q4 | which error does a misplaced plugin raise, and what does it say? | Raised in ADR 0019's own open:. Narrowed 2026-09-11. Build 3. RULED the same day: PluginSetupError, and the message says where to install it. The recommendation below was taken whole. No new error type ships. |
Q5 | which rule wins when two plugins both answer yes? | Raised in ADR 0018's own open:. Build 2. CLOSED 2026-09-11. Half of it was never open, and the author said so. |
Q6 | does a parent's duration count the gaps? | Raised 2026-09-11, while closing Q1. Build 1. CLOSED: the gaps count. The duration Field is the row's own span on every row. |
Q7 | what reads a Field off a row the store does not hold? | Raised 2026-09-11, in the author's plan review. Build 1. RULED the same day. Unit D is unblocked. |
Q8 | does the public Plugin name want a different word? | Raised 2026-09-12, Build 3. Not blocking, and the ADR's name shipped. |
Q9 | should EntryEdits and ProposedEdits key on a loose string? | Raised 2026-09-12, Build 4, by the doc-examples gate. Not blocking, and out of ADR 0020's scope. |