Skip to main content

Superseded by ADR 0011, accepted and built 2026-09-11. This ADR's Field rules are history: the consumer's bag is props, not meta, and FieldSource is deleted. Do not rewrite the body. ADRs 0012, 0013, 0015 and 0016 built the rest of the redesign around it. The sixth was withdrawn and deleted (the gap at 0014).

One argument in this body is already void. This ADR rejected flat consumer keys partly because "the one thing a grid does not do: we serialize". ADR 0016 deletes the save format, so that charge no longer stands. The rejection survives on its other reasons — see ADR 0016's The one question this raised. The body stays as written, because an ADR records the reasoning of its day.

Fields are declared, and grid columns reference them

A consumer field had no home. Entry is a closed shape — name, kind, parentId, start, end, progress, segments, meta — and meta is opaque by promise (D-S2-12: anything of yours goes in meta and survives byte for byte). So a consumer's cost could be stored but never aggregated, never compared per field, and never shown by anything except a hand-written callback. plans/02 §2 recorded the result honestly: two column shapes, one for core ({ type: 'name' }) and one for consumer data ({ id: 'team', value: t => t.meta.team }). The second shape has no rollup, no equality rule, no editor and no undo granularity, because a callback that reads a value cannot supply any of them. Issue #80 asked how a parent derives each field from its children and could not be answered, because core had no way to name a field that is not one of its own.

A field is now a declared thing, and core fields are declarations like any other. One Field says where a value lives (entry, meta, or computed), how it rolls up to a parent, how two of its values compare, and how it reads as text. name, start, end and duration ship in the same registry a consumer adds to. progress is not among them — it is scheduling-plugin data (ADR 0008). A GridColumn carries presentation only and names a field that declared column, so 'start' and 'cost' go through one code path.

One sentence separates the pair, and it is the only one a reader has to hold: a field is what a value is; a grid column is where a Gantt shows it. The names say their side — fields sits on the Dataset beside entries, gridColumns sits on the Gantt beside gridWidth. A field also declares its own column defaults, so gridColumns: ['name', 'start', 'cost'] is names in display order and per-Gantt overrides, not a second definition system. Aggregators are registered by name, so rollUp: 'sum' is data that serializes rather than a function that cannot. EntryEdit accepts declared keys, so update('t1', { start: X, cost: 500 }) is one transaction, one changeset and one undo step across a core field and a consumer field.

The stored-versus-computed question that #80 §3.2 called the hard one is answered by the declaration instead of by a flag: a field's source decides. A field sourced from entry or meta has a stored home, so its rolled-up parent value is stored — in the changeset, in undo, in the document, which is exactly what data/span-rollup.ts already does for start/end. A field sourced from compute has no home, so its parent value is computed on read and cached, and never reaches the document. A consumer who wants a sum without document bytes declares a computed field rather than setting an option.

Considered options​

  • Keep Entry closed and let consumers pass a value callback per column (status quo, plans/02 §2). Rejected: it solves display and nothing else. The value cannot roll up, cannot be edited, cannot be compared for the per-field equality rule, and cannot appear in a changeset — so a consumer field is second class at four separate seams, and #80 has no answer.
  • Make Gantt, view/ and layout/ generic over the consumer's field map. Rejected: the parameter would propagate through every internal signature to serve a TypeScript-boundary concern those layers never use. Gantt stays unparameterized. Dataset<TMeta, TFields> and EntryEdit<TMeta, TFields> are allowed: they type update({ cost }) and fieldValue on the Dataset the consumer already holds, and they stop at that class. They do not flow into the Gantt, the shell, or the frame. Typed reads still go through the Field key (dataset.entries.fieldValue('t1', 'cost')); a token API is not required.
  • Put the aggregate function on the column, as a comparable data grid's aggFunc does. Rejected, and this is the one part of that design we deliberately do not copy: a stored value would then depend on whether a column is currently visible. That grid can afford it because its aggregation is view-side and never written back; ours writes start/end into the store. #80 §3.1 reached the same conclusion from the other direction.
  • Take a bare function for rollUp instead of a registered name. Rejected: a function is not data. It cannot appear in a document, so toJSON output would depend on code that does not travel with it, and a second application reading the same document would compute different values with no way to detect the difference. A name can be refused when it is not registered.
  • Drop meta and let a consumer declare top-level keys on Entry, as a comparable data grid's row data works. Rejected, and the reason is the one thing that grid does not have to do: we serialize. meta is not the consumer's API — it is the consumer's namespace in a versioned document. Top level belongs to the schema; meta belongs to the consumer (D-S2-12). Remove the namespace and every core field we add later is a breaking change for whoever already used that name, and fromJSON's rule that keys the reader does not know are dropped (README:325) becomes unimplementable, because an unknown consumer key and a key from a schema we do not read stop being distinguishable. That grid never meets this because it neither persists your data nor adds fields to your object. Declaring does not change who owns a key; it makes a key in the consumer's namespace addressable.
  • Require a declaration for every consumer key, so meta holds nothing. Rejected: passenger data is real. A sourceSystemId that is never shown, aggregated or edited through the library still has to round-trip, and making its owner write a Field for it is friction with no payoff. Undeclared keys keep travelling byte for byte.
  • Give consumer fields their own store, separate from meta (close to #16's plugin stores). Deferred, not rejected. Declaring a path into meta needs no document-format change, because meta is already serialized byte for byte. A separate store is the right answer for plugin-owned data, where host and plugin can collide in one field; a consumer declaring their own key in their own meta has nobody to collide with.

Consequences​

  • A computed field reads the dataset only, never view state. No zoom, no visible range, no selection. S4 keys the cache on dataset revision (D-S4-10) — coarser than a subtree revision, never stale. A per-entry subtree key returns at S6 if the spike says so. A value that depends on the view is a renderer's business, not a field. This is the one rule that would quietly make a shared Dataset break, so it is written down rather than assumed.

  • meta survives, and is demoted from surface to storage. After this ADR a declared field is addressed by its field key, never by a path: the consumer writes update('t1', { cost: 500 }) and reads through the field, so where the value sits is an implementation detail the spec picks. That leaves two tiers with one sentence each — a declared field is addressable, editable, aggregatable and comparable; undeclared meta is opaque passenger data that round-trips byte for byte. It also frees the storage: moving declared values out of meta later changes no consumer call site.

  • meta is not a comparable data grid's RowNode, and the two must not be conflated. A RowNode is the library's derived state about the consumer's row — selection, expansion, row index. meta is the consumer's data on the library's entry. They sit on opposite sides of the authored/derived line. Our RowNode analogue is Row plus the view-state model, and neither one is a reason to drop meta.

  • meta's promise gains one clause. It is opaque unless you declare a key. Declaring is the consumer's own act, and undeclared keys keep the current guarantee — carried by reference, never walked, compared by === only. A write to a declared key emits a changeset row keyed on the field key (cost), never a meta row. A whole-meta write emits one row per changed declared key plus one meta row for the rest, or undo of a declared-field edit would restore the whole meta object and lose a concurrent write. This edits D-S2-12 and README:205.

  • The changeset's field key must not close. s2.2-transactions-and-changesets.md typed it keyof Omit<Entry, 'id'> under the name EntryField, and it appears in FieldUpdated and the undo record — both public. It becomes an open key type validated at runtime against the registry (D-S2-26). An edit naming an unregistered key is an UnknownFieldError, not a silent write.

  • The comparator map moves onto the field. D-S2-7's satisfies Record<FieldKey, FieldComparator> cannot stay exhaustive over an open union. Core fields keep their comparators as shipped specs, and the exhaustiveness check narrows to the core set; a consumer field supplies equals or gets Object.is.

  • The frame carries cells, not one label. FrameRow.label: string (src/layout/frame.ts:32) is a one-column grid baked into layout/ — it is set from entry.name at frame.ts:203, and plans/01 §2.2's Row.label says the same. It becomes cells: readonly string[], one per configured column, in column order, filled through each field's formatValue. This is library-derived text on the a11yLabel precedent, so plans/01 §4's no consumer render output in the frame rule holds and the backend still consumes only the frame. Tracked as an S1 fix.

  • Two ways to turn a value into something visible, and they stay separate. Field.formatValue returns text, is DOM-free, and fills the frame's cells. A grid column's cellRenderer returns element descriptions and is applied by render/. This is the same split FrameBar.label and barRenderer already use.

  • A field type is one registration stored once. fieldTypes bundles data semantics (rollUp, equals, formatValue) and may carry a column sub-object of presentation defaults. The Dataset registry holds the whole declaration. data/ carries column bytes and does not interpret them. view/ reads them when it resolves Grid columns. Do not open two registries.

  • D-S2-22 is unchanged on precedence and on ownership. The Rollup is not an extender occupant: scheduling cannot replace it with a different engine. S4 keeps the S2 leaf: data/rollup.ts has one importer, data/transaction.ts (rollup-is-removable). Default is on. rollUpKinds: 'none' keeps authored parent values. Precedence is unchanged: the pass yields to a field the body proposed and wins over one the resolver proposed. Bottom-up, one pass. rollUpKinds says which kinds derive; the registry says how each field derives.

  • Fields belong to the Dataset, and this does not depend on how many Gantts exist. Two reasons, each sufficient on its own. Lifecycle: a Dataset is built before any Gantt, and the rollup runs at construction and on fromJSON (D-S2-12), so a newly built group already has a real span before anything mounts — config in a view object would have nothing to read. Blast radius: the rollup writes into Entry.start/end, which are stored, serialized and undoable, and gantt.gridColumns is live-reconfigurable, so aggregation on a column would let a view assignment change the document. The multi-Gantt argument is a third reason and the weakest, and it is not relied on here.

  • Two Gantts over one Dataset stays possible and stops being designed for. It costs the library nothing — a second Gantt is a second subscriber to dataset.on('change') (D-S2-24), and nothing counts them — so nothing is forbidden. What is dropped is the S5 acceptance box and the side-by-side harness demo; one Gantt switching gantt.rowSource proves the same Row ≠ Entry payoff and proves live reconfiguration as well. D9 is untouched: its own example is a delivery-schedule Gantt above a workforce Gantt, which shares an axis and a scroll, never data. plans/02 §5's example is corrected to match.

  • Naming. Field and GridColumn, with FieldKey the one name for a field's name — this retires EntryField, which meant the same thing in the changeset and gave two names to one concept (the #7 precedent). fields is unqualified because it sits next to entries on the Dataset and its subject is already clear; gridColumns carries the qualifier because a bare columns on a Gantt does not say whether it is data or display, and grid is already this repo's word for that pane (gridWidth, RenderSurfaces.grid). The qualifier also scales: plugin stores declaring their own fields later read as dependencyFields, not as a collision.

  • Slice placement. The registry ships in S4 with the tree and the grid, which is where the first consumer field and the first second column exist. S2 must only leave FieldKey open (D-S2-26) and settle the meta clause; both are cheap now and are breaking changes to a public type later. S5's "column types (name, start, end, duration, custom value/renderer)" becomes column presentation over shipped fields rather than a second definition system.

  • Aggregator that throws. The transaction rolls back, with AggregatorFailedError (D-S4-9). Keeping the stored value would leave a parent that no longer follows its children, with nothing said.

  • One path for every Aggregator. Shipped and consumer Aggregators both recompute the ancestor chains of the touched entries (D-S4-8). A two-speed design would give a consumer Field second-class commit semantics.

  • progress left this ADR's core list. See ADR 0008. S4 removed progress from Entry and the Document; the scheduling plugin reintroduces it as plugin-owned data with a registered Field in S7.

  • TFields stops at the Dataset, and FieldContext.read types core keys only (#144). dataset.entries.fieldValue('t1', 'cost') reads as number | undefined because EntryStoreView carries the same TFields the Dataset does — the key types the read, with no type argument at the call. A FieldContext cannot make that promise: it is handed to a filter, a groupBy, an Aggregator and a column resolve, so it travels into layout/ and view/, and making those layers generic over one consumer's field map is the second option this ADR rejected above. So read types the shipped Fields (start reads as an Instant, duration as a Duration) and answers unknown for every other key, which a caller narrows where it stands. This names where the rejected option bites, and changes nothing the decision above did not already settle.

  • A declaration knows who declared it, and a Document carries the consumer's only (D-S5-33, issues #162/#181). S5 let a plugin declare a Field (ctx.fields.register) and a grid column (ctx.view.registerGridColumn). Both now record the calling plugin's id, and both consumer-facing surfaces report the consumer's own declarations alone: toJSON writes the Fields the consumer declared, and gantt.gridColumns plus both halves of a gridColumnsChange payload carry the columns the consumer authored — before and after a resize, a reorder or any other commit. This is the same instinct as "an Aggregator is referenced by a registered name": what travels in a document must be something the reading application can act on. A plugin's declaration is not. It is code, and the plugin makes it again on its next install, so a Document that carried it would author a declaration with nothing behind it and would collide with the plugin's own registration on the next read. Note the deliberate asymmetry with a plugin store: its rows are data the plugin cannot rebuild, so a Document keeps them under their owner's id as passenger data (D-S5-24). Values are unaffected in both cases — a plugin Field's values sit in Entry.meta, which round-trips whether the Field is declared or not. A schema: 3 Document written before this decision can still carry such a declaration, and it is not supported (#192): the row records no declarer, a consumer's own declaration of that key writes the same bytes, and no migration can tell the two apart. Reading one with that plugin throws PluginSetupError wrapping DuplicateFieldKeyError, which names both the plugin and the key. src/data/serialization/read.ts carries the reasoning.

  • Field<TValue> checks only at its declaration site, on the record (#141 item #4). FieldRegistry, DatasetOptions.fields and FieldLookup all hold bare Field (that is, Field<unknown>) — the registry is a heterogeneous, string-keyed, runtime-declared collection by design, the same reason fieldTypes bundles a stored registration rather than a compile-time schema. TValue types equals/compare/formatValue/parseValue against each other at the point a Field is declared and nothing downstream re-checks it. Full propagation (a closed, compile-time key set shaped like { [K in keyof Schema]: Field<Schema[K]> }) would need a schema the library does not otherwise have — a different library, not a quality fix to this one. Choosing it later, once a consumer's own generic Dataset<TFields> exists, is still open; today it is not the trade this ADR makes.