Skip to content

feat(spec): declare element-level navigation on object-kanban / object-calendar and give object-timeline its ComponentPropsMap row (#17987) - #19219

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-17987-element-navigation-declaration
Sep 20, 2026
Merged

os-steve merged 3 commits into
mainfrom
claude/issue-17987-element-navigation-declaration

Conversation

@os-steve

@os-steve os-steve commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #17987

Clause-②: yes (widening) — one new optional key on two published element faces plus one new ComponentPropsMap row. Nothing previously admitted is refused, nothing is renamed or retired.

Executes the objectui#8652 maintainer ruling, reply verbatim 「B」 — declare navigation on the platform element schemas. Options A / C / D were not taken and are not reopened here.

What landed

  1. object-kanban and object-calendar declare navigation, carrying NavigationConfigSchema — the def ListViewSchema.navigation already declares, taken BY REFERENCE so the vocabulary cannot fork. The retired navigation.view tombstone rides along onto both element faces.
  2. object-timeline gets its ComponentPropsMap row. It was a registered objectui renderer reachable only through the component type union's open string arm with no row here, so the props gate skipped it: unjudged in both directions. The row declares the key set measured from the renderer's own read points at the .objectui-sha pin 53ded82b — objectName, timeline, filter, sort, limit, data, items, variant, dateFormat, rowLabel, minDate, maxDate, descriptionField, mapping, navigation — with the per-key citations, and what it deliberately does NOT declare, in the schema's own header.
  3. The view-level navigation on ListViewSchema is untouched. The element key is an ADDITIONAL carrier for the standalone placement, not a replacement; a test pins that half so a later sweep cannot fold the two into one question.

Premise re-derived on today's origin/main before any edit

Base 24d622b94b8afe93d00deda39062bf292e0d5d2f. The card's own numbers had moved, so every leg was re-measured rather than inherited:

leg card said measured at base
ComponentPropsMap entries 45 48 (49 after this PR)
object-kanban + navigation refused by name refused, unrecognized_keys: ['navigation']
object-calendar + navigation refused by name refused, identically
object-timeline row absent absent
view face carries navigation carries it, and still does

Controls, in the same run, so the zeros are readings:

  • Positive control that fires — object-kanban + cardFields and object-calendar + defaultView both success: true at base, i.e. these faces were not refusing everything.
  • Bogus-key control — zzzDefinitelyNotAKey refused through the same unrecognized_keys channel on both faces, before and after: the refusal of navigation was the generic unknown-key rule, and it still is for an undeclared key.
  • Firing control for the timeline zero — object-kanban / object-calendar / object-grid / object-tree all resolved in the same call, so "no entry" was a reading rather than a dead probe.

The card's acceptance criterion, run

ComponentPropsMap['object-kanban'].safeParse({ objectName: 'task', navigation: { mode: 'drawer' } }) returns success: true, ComponentPropsMap['object-timeline'] is defined, and the bogus-key control still returns success: false through unrecognized_keys. All three legs, plus the control, are pinned in packages/spec/src/ui/component-element-navigation-17987.test.ts (12 tests at the current head; the sentence above is the card's criterion, and the leg added in round 2 sits beside it). The parsed state is the view face's: { mode: 'drawer', preventNavigation: false, openNewTab: false, size: 'auto' }.

The "cannot fork" clause has teeth rather than prose: the element door is asserted to resolve to the very object ListViewSchema.navigation carries, and the two faces' accept/refuse verdicts are asserted EQUAL over a five-document battery that contains both verdicts (so the equality cannot hold vacuously).

Reverse verification — the fix committed first, then mutated on disk

⚠️ The table below is ROUND 1, taken against head 02d81323c9. Its numbers and blobs are that head's and are left exactly as measured — ⛔ not restated as if they were the current head's. The round-2 leg follows it.

Both legs went through scripts/ablation-replace.mjs, so the write is proven by anchor counts and blob hashes rather than by an exit code, and each restore is proven by blob == HEAD with git diff HEAD empty.

ablation predicted observed
rename the navigation door on all three faces (anchor x3 → x0, blob 9b38bfa56e57 → 4f12296e0afc) red on the acceptance legs, controls unmoved 5 failed / 6 passed — the bogus-key control, the no-default leg, the timeline-row-defined leg, the view-face leg and the two timeline-key legs all stayed green
rename the object-timeline row key (anchor x1 → x0, blob 9b38bfa56e57 → e2b329749e22) red on every timeline leg 9 failed / 2 passed — the two survivors are the view-face leg and the TimelineConfigSchema prescription leg, neither of which depends on the row

Restored to 9b38bfa56e57b72e630540950d55f8fd09b6bee2 both times; the suite is green on the restored tree.

Round 2, head ee50b4cd13 — the data door mutated through the same tool (anchor x1 → x0, blob d663b3c630d6): predicted red on exactly the two legs that author data and nothing else; observed 2 failed / 10 passed, with the bogus-key control on that same face staying green and its refusal naming the bogus key and not data. Restore proven by blob == HEAD with git diff HEAD empty. ⭐ The round-2 contract reviewer re-ran this leg independently with a different replacement string and read the same 2 failed / 10 passed.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives 107 families for this diff. All 107 were run and reconciled with --ran: 107 run, 0 NOT-MEASURED, 0 UNRUN.

  • pnpm --filter @objectstack/spec build then check:generated — green; six artifacts were regenerated by --fix (api-surface, api-surface-declarations, export-origins, declaration-map, the reference pages, the strictness ledger) plus gen:schema's authorable-surface and json-schema manifest.
  • pnpm --filter @objectstack/spec test — 500 files / 14611 tests passed at the ROUND-1 head 02d81323c9; typecheck green.
  • ⭐ At the current head ee50b4cd13: 500 files / 14612 tests passed — read twice independently, by the implementer and by the round-2 contract reviewer, and typecheck green in both.
  • pnpm lint — repo-wide, exit 0. Not a narrowed run: the whole eslint . --no-inline-config universe.
  • check:react-declaration-parity in CI's own spelling (MANIFEST="$PWD/sdui.manifest.json" … --baseline react-declaration-parity.baseline.json --strict) exits 0: object-timeline reads 2 declared by both, 13 spec-only, 0 registry-only, so the new row needs no baseline entry and the ratchet reports no new divergence. ⚠️ The BARE form in the derived list exits 1 on purpose — with no MANIFEST the gate reports "did not run" rather than skipping — and that non-zero in ran.list is that refusal, not a divergence.
  • The heavy runs went through scripts/pm/os-verify-lock.sh; the six path-scheduled CI jobs, the artifact-roster families and the wide-population families are CI's farm and are NOT measured here.

Two files outside the card's declared surface, named rather than widened silently

The card's surface is packages/spec/src/ui/ plus the changeset and the generated pages that follow. Two more tracked files had to move, both mechanically and both printed by the gate that refused without them:

  • packages/spec/dropped-refinements.baseline.json — the build REFUSES until ui/ObjectTimelineProps declares the two sites its projection cannot carry (filter.element, timeline.groupByField), and prints the entry verbatim. It is the same row ui/ObjectGanttProps already carries. Its header totals moved with it (204 schemas / 560 sites), which scripts/dropped-refinements.test.ts asserts.
  • docs/audits/2026-07-unknown-key-strictness-ledger.counts.md — regenerated by gen:strictness-ledger: one more closed object site in ui/.

Acceptance notes — observed in passing, not filed, not fixed here

  • object-grid, object-gantt and object-tree declare navigation as z.unknown(). Three element faces therefore accept navigation: { mode: 'drawr' } and navigation: 'drawer' while the three this card touches now judge the value. Narrowing a published accept face is a different act from widening one, it is outside this card's closed scope, and it deserves its own ruling. Reported to the seat with dedupe words rather than filed from here.
  • objectui's ListViewTimelineConfig declares a nested timeline.dateField alias that TimelineConfigSchema refuses by name, and the renderer reads it. That is the same defect shape as this card on a different key, on the VIEW face, so it is neither in scope nor fixable as a rider. Reported likewise. This PR's guidance prescription is careful about it: it names timeline.startDateField, the key the alias resolves to and one TimelineConfigSchema accepts — the trap spec: ComponentPropsMap['object-calendar'] prescribes calendar: { ..., allDayField } — a shape its own CalendarConfigSchema refuses by name #17054 already paid for once, pinned in the new test.
  • object-tree reads "NO component in the manifest" in the parity gate. Pre-existing, untouched, and outside the baseline's ratchet.

Downstream

objectui#8652, objectui#8654 and objectui#8651's CalendarSchema arm wait on this. ⚠️ Their unlock criterion is consumer-installable — a released @objectstack/spec carrying the declaration, installable there, with the pin moved — ⛔ not this merging.

Body edited by the dispatching seat domain:spec#4 (session_01AmH9bKvGoLjiY86Q4Z3og2) after the rework landed at ee50b4cd13e07a2d3327e78d5f98aefd30ed85ab: the object-timeline key list gained data, and the parity figure moved from 12 to 13 spec-only. The implementer named both edits in its report and correctly declined to patch this body itself — a dev writes a PR body once, at the POST that opens it.


Generated by Claude Code

…ine` its props-map row

`ComponentPropsMap` refused `navigation` by name on `object-kanban` and
`object-calendar` while objectui's renderers read and honour it on a standalone
node, and `object-timeline` had no row at all, so the authoring gate skipped it
in both directions. Both faces now carry `NavigationConfigSchema` — the same def
`ListViewSchema.navigation` declares, by reference — and the timeline row
declares the key set measured from the renderer's read points at the
`.objectui-sha` pin. The view-level carrier is untouched.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…ion moves

`gen:schema` / `gen:docs` and the four dist-read snapshots follow the new keys:
the authorable surface gains `ObjectKanbanProps:navigation`,
`ObjectCalendarProps:navigation` and the fourteen `ObjectTimelineProps` keys, the
dropped-refinement ledger gains the timeline row the projection cannot carry
(`filter.element`, `timeline.groupByField`) with its header totals, and the
strictness ledger counts one more closed object site in `ui/`.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 14 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/ui.txt, packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via dateField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE))
  • content/docs/concepts/architecture.mdx (via groupByField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE))
  • content/docs/data-modeling/formulas.mdx (via dateField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE))
  • content/docs/getting-started/common-patterns.mdx (via groupByField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE))
  • content/docs/protocol/objectui/layout-dsl.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/ui/field-grouping-and-order.mdx (via groupByField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE))
  • content/docs/ui/views.mdx (via colorField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE), endDateField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE), groupByField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE), startDateField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE; a string literal in examples), titleField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE; a string literal in examples))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via dateField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE), groupByField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE))
  • content/docs/releases/v17/17-1.mdx (via ComponentPropsMap (symbol, a top-level const object), titleField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE; a string literal in examples))
  • content/docs/releases/v17/17-3.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/releases/v9.mdx (via groupByField (literal, a string literal in OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 7 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/ui.txt, packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0046a41b43da8e7d2db9d7f926cc0518a357c379 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ade56b521cb00acdf79c4d9e65ced8b7030d01c7 — the merge of head ee50b4cd13e07a2d3327e78d5f98aefd30ed85ab into base 0046a41b43da8e7d2db9d7f926cc0518a357c379, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ade56b521cb00acdf79c4d9e65ced8b7030d01c7 && git checkout ade56b521cb00acdf79c4d9e65ced8b7030d01c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0046a41b43da8e7d2db9d7f926cc0518a357c379 ee50b4cd13e07a2d3327e78d5f98aefd30ed85ab && git checkout -B drift-repro 0046a41b43da8e7d2db9d7f926cc0518a357c379 && git merge --no-ff ee50b4cd13e07a2d3327e78d5f98aefd30ed85ab

node scripts/docs-audit/affected-docs.mjs --json 0046a41b43da8e7d2db9d7f926cc0518a357c379

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0046a41b43da8e7d2db9d7f926cc0518a357c379 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Reviewed at 2026-09-20T00:25Z · contract-tier review of record for PR #19219 (card #17987), domain:spec lane, Clause-②: yes (widening).

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 02d81323c9324f83053b85f8e1970c28ca4903c1

Measured on a detached checkout of the head (merge-base with origin/main at the time of the read: 24d622b94b8afe93d00deda39062bf292e0d5d2f; the diffstat there is the stated 13 files, +947/−25). Every reading below was re-derived from the tree, the built dist, and the objectui checkout at the .objectui-sha pin 53ded82bf7a494f54e344e19099dbf00854b8694 (the pin recorded at both base and head) — never from the PR body or the implementer report. Exit codes were captured before any pipe. Where a claim could not be verified from the tree it is marked NOT MEASURED.

① Derived judgments

1. Accept-set movement is widening only — PASS. Instrument: a script that loads ComponentPropsMap at base and at head and records, per face, the sorted key set, a per-door JSON-schema projection hash (input io and output io, unrepresentable: 'any') and a whole-face projection hash; the two dumps are then compared key by key. Reading: 48 faces at base, 49 at head. object-kanban: added [navigation], removed [], retyped []. object-calendar: added [navigation], removed [], retyped []. New row object-timeline with 14 keys. The remaining 46 faces read identical in key set, every per-door projection and the whole-face projection (the untouched-face control). Firing control for the instrument: it is the same comparison that reported the three non-zero deltas above, so its 46 zeros are readings.

2. The carrier is the shared def, and the identity pin is real — PASS. NavigationConfigSchema is declared once (view.zod.ts:1863) and imported into component.zod.ts; the diff mentions it only at the import and the three navigation: NavigationConfigSchema.optional() doors. lazySchema's proxy binds every method to the one cached real instance, so .optional() on the view door and on each element door captures the SAME real object as innerType — which is what the test's innerOf compares. Would it fail against a copy? Measured, not assumed: ablation leg F replaced all three doors with NavigationConfigSchema.describe('forked copy').optional() (a behaviourally identical clone; anchor x3 → x0, blob 9b38bfa56e57 → 749fc08a242f) and the suite read 1 failed / 10 passed — exactly the identity test («carries a COPY of the navigation shape») went red while the behavioural-equality battery stayed green. That is the discriminating case a decorative pin would miss. Restore proven: blob 9b38bfa56e57 == HEAD, git diff HEAD empty.

3. The view-level carrier is untouched — PASS. view.zod.ts is absent from the diff (0 matching paths). Nothing in the diff mutates the def (.optional() and .describe() clone their wrapper; the identity test passing at head shows the inner def is the unchanged instance). The src/ui spec test subset at head: 72 files / 2815 tests passed, exit 0, including the existing view-face navigation and navigation.view tombstone pins.

4. ⭐ The object-timeline row's key set — BLOCKING (see below). Re-derived from the pinned objectui source, reading plugin-timeline/src/ObjectTimeline.tsx (670 lines, in full), renderer.tsx (the TimelineRenderer destructure at 1211-1217 plus schema.minDate/schema.maxDate/schema.rowLabel/scale at 1436-1499) and the registration in index.tsx:336-364. Every top-level key the renderer reads, and its disposition in the row:

  • declared and read: objectName, timeline, filter, sort, limit, items, variant, dateFormat, rowLabel, minDate, maxDate, descriptionField, mapping, navigation — all 14 have read points; no declared key is unread;
  • read and refused with a prescription (by design, consistent with the family's one-key-per-concept posture): titleField, startDateField, dateField, endDateField, groupByField, colorField, scale;
  • read and undeclared with a stated reason I accept: bind (objectui data-scope key, not declared on any sibling face either), className (node-level on PageComponentSchema, page.zod.ts:336), onItemClick/onRowClick (callbacks);
  • read, honoured, and refused BY NAME with no reason given: data. See the BLOCKING finding.

Nested, for completeness: timeline.dateField (read at :287) and timeline.metaFields (read at :332-334) are both refused by the shared TimelineConfigSchema; inherited from the view face by taking the def by reference, not introduced here — noted below.

5. Reverse verification — PASS. Re-run in an isolated worktree through scripts/ablation-replace.mjs (WRAP mode, evidence by anchor count and blob hash). Leg A, rename the navigation door on all three faces (--expect 3, blob 9b38bfa56e57 → affb4bdd1171): 5 failed / 6 passed, exactly the implementer's reading — red on the acceptance leg, the identity leg, the behavioural-equality leg, the tombstone leg and the timeline key-set leg; the bogus-key control, the no-default leg, the row-defined leg, the view-face leg and the two TimelineConfigSchema legs stayed green. Restore proven: blob 9b38bfa56e57 == HEAD, git diff HEAD empty. Leg F (mine) is in item 2. A third leg is in item 6. Green baseline at head before any mutation: 11/11, exit 0.

6. The files outside the declared surface — PASS. From a fresh pnpm --filter @objectstack/spec build (exit 0) at head, check:generated reports all 16 generated artifacts up to date (exit 0) — that covers api-surface-declarations/ui.txt (+284/−2, check:api-surface-declarations from the built dist), docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (check:strictness-ledger), the reference pages, the authorable surface and the JSON-schema manifest, each compared to what the generator produces from this tree. packages/spec/dropped-refinements.baseline.json is hand-edited by design (its module header: no gen: script on purpose, so a new gap is a reviewed line, not a command) and is verified by the build in both directions. Firing control: deleting the new ui/ObjectTimelineProps entry (anchor x1 → x0, blob 09732185ca7e → 406af8f0f032) makes gen:schema refuse with «1 published schema(s) drop a refinement and are not declared … + ui/ObjectTimelineProps (2 site(s))», exit 1; restore proven blob 09732185ca7e == HEAD. The header totals (204 / 560) are asserted by scripts/dropped-refinements.test.ts (27 passed) and the ledger doc by the two strictness-ledger tests under the repo project (36 passed). Not hand-typed numbers.

7. The changeset — PASS on level, prose must move with the row. @objectstack/spec: minor is the right level for a new optional key on a published surface under Clause-②: yes (AGENTS.md: yes takes at least minor). Gates at head: check-adr-0087-registration (non-breaking, 1 changeset), check-empty-changeset, check-changeset-no-major all exit 0; check-clause2-carriers.mjs --pair 19219 exit 0 (run at 2026-09-20T00:19:46Z; the declaration is readable and the label is in the same state on card and PR). The prose does not overstate the navigation half or the view-level half. It understates the timeline half: the sentence «declares the key set measured from the renderer's own read points … and refuses everything else» publishes the refusal of data as a claim — see BLOCKING. When the row is corrected, this list and the reference page move with it.

Also measured: check:react-declaration-parity in CI's spelling (MANIFEST=$PWD/sdui.manifest.json … --baseline react-declaration-parity.baseline.json --strict) exit 0 at head, object-timeline: 2 declared by both, 12 spec-only, 0 registry-only — the PR body's figure reproduces. check:objectui-pin-citations exit 0 with the pinned checkout present: 26 asserting citations match the pin and all 7 anchor-content assertions verify; I also read ObjectKanban.tsx:689/697, ObjectCalendar.tsx:597/599/809 and renderer.tsx:1215/1436/1450/1499 directly — the cited text is there. The PR's required CI contexts were still in_progress when read (Lint & Repo Gates, Type Check · workspace, four Test Core shards) — their conclusions are NOT MEASURED here; the equivalent gates were run locally as listed.

BLOCKING — the new object-timeline row refuses data, a key the pinned renderer honours on a standalone node

Inputs → wrong outcome, all at head and at the pin:

  • Renderer (pin 53ded82b). ObjectTimeline.tsx:171 initialises loading off (props as any).data; :247 skips the object query when it is present; :256 rawData = (props as any).data || boundData || fetchedData feeds the rail. The read is off React props, and SchemaRenderer.tsx delivers it from authored metadata: the properties hoist (~1057-1066) copies every properties.* key except type/id onto the node, and createElement (1576) spreads every non-metadata node key as a prop; data is not on the strip list (1467-1479). Runtime probe through SchemaRenderer + the real registry, no view ancestor, spy adapter: { type: 'object-timeline', objectName: 'campaign', timeline: {…}, properties: { data: [{ name: 'ROW_FROM_AUTHORED_DATA', … }] } } renders that row inside timeline-canvas and dataSource.find is never called; the control without data calls find and renders no such row. 2/2 passed, exit 0.
  • Spec door (head). ComponentPropsMap['object-timeline'].safeParse({ objectName, timeline: {…}, data: [ … ] }) → success: false, unrecognized_keys: ['data']. Controls in the same run: the same document without data → success: true; object-kanban and object-calendar → success: true (both declare data as z.array(z.unknown()).optional() — «Static inline cards — bypasses the object query» / «Pre-fetched records — skips the internal fetch», the same semantics the timeline read has); object-grid/object-gantt/object-tree/object-map refuse only on VALUE (invalid_type; there data is a declared ViewDataSchema door). object-timeline is now the only object-bound face in the map that refuses the key by name.
  • Authoring gate (head, packages/lint validateComponentProps). A page node pages[0].regions[0].components[0] of type object-timeline with properties.data → warning component-props-unknown-key «data is not a prop object-timeline declares … hint: Remove data, or declare it on object-timeline's props schema if the component honours it». Same call: object-kanban and object-calendar nodes with the identical properties.data → no finding; a zzzDefinitelyNotAKey node → the same warning (firing control).

This is the card's own defect shape — «the same document runs correctly and fails os check» — reproduced on a face that previously refused nothing (at base the type had no row and the gate skipped it). The row's header lists what it deliberately does not declare and gives a reason for each; data is not in that list — the measurement read schema.* and missed the props.* read that SchemaRenderer feeds from authored metadata. Two honest resolutions, either one the implementer's and the seat's, not mine: declare data on the row the way object-kanban/object-calendar do (with its read points), extending the test's accept battery, the changeset's key list and the regenerated artifacts; or obtain a ruling that data on this block is a host-only channel and retire the objectui read under that ruling — in which case the row must say so in its «deliberately NOT declared» list with the ruling cited. Until one of those lands, the row's key set is not the renderer's.

noted (not blocking)

  • timeline.metaFields is read by the renderer (ObjectTimeline.tsx:332-334, inline chip fields) and refused by the shared TimelineConfigSchema, exactly like the timeline.dateField alias the PR's acceptance notes already report — but metaFields is not reported. Pre-existing on the view face, inherited by taking the def by reference (the ruling's own requirement), so it belongs to TimelineConfigSchema and a separate card, not to this row. Worth adding to the acceptance notes beside dateField.
  • variant: 'gantt' is declared although the object-bound path refuses it with a named diagnostic (:518); the row's header documents this and it is a value-with-diagnostic, not a silent drop. No action.
  • The strictness-ledger*.test.ts pair lives under the repo vitest project, not local; both pass there. No action.

② Semver level

minor on @objectstack/spec — correct for Clause-②: yes (widening): one new optional key on two published faces plus one new strict row; no key renamed, retyped, defaulted differently or removed (item 1). The level stands whichever way the BLOCKING finding is resolved (declaring data is a further widening).

③ Boundary flags

  • Governed surfaces: none touched (packages/spec/**, docs/audits/**, content/docs/references/**, .changeset/** only).
  • view.zod.ts / ListViewSchema.navigation: untouched, verified (item 3).
  • Sibling pin: .objectui-sha unchanged at base and head; nothing removed or renamed, so the Console Pin Gate has nothing to break.
  • Downstream unlock stays consumer-installable, as the PR states; not this merge.

Implemented-by: claude/issue-17987-element-navigation-declaration
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: FAIL


Generated by Claude Code

… honours it

The first cut of this row measured `schema.*` read points only, so it declared
fourteen keys and refused `data` by name — on the one face that, before the row
existed, refused nothing at all. `ObjectTimeline.tsx` reads the key off REACT
PROPS (`:171` seeds loading, `:247` skips the object query, `:256` is the row
source itself, ahead of the data-scope binding and the fetch), and an authored
`properties.data` reaches that channel through `SchemaRenderer`'s hoist and
prop spread. The door takes the shape its two sibling object-bound faces
already declare, so the third face is not a second vocabulary, and the door's
docblock keeps the reason a `schema.*`-only sweep missed it.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Reviewed at 2026-09-20T01:23Z · contract-tier review of record, round 2, for PR #19219 (card #17987), domain:spec lane, Clause-②: yes (widening). This is the narrow re-review the seat scoped after round 1 (PR comment 5746400707, 2026-09-20T00:25Z, VERDICT: FAIL; rework ruling on card #17987 comment 5746413810, 2026-09-20T00:28Z). Round-1 items 1, 2, 3, 5, 6 and 7 are recorded as closed and are not re-litigated here — they are only checked for disturbance by the delta.

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee50b4cd13e07a2d3327e78d5f98aefd30ed85ab

Measured on a detached worktree of the head (merge-base with origin/main at the read: 24d622b94b8afe93d00deda39062bf292e0d5d2f; the whole PR is 13 files, +1026/−25; the delta against the reviewed head 02d81323c9324f83053b85f8e1970c28ca4903c1 is one commit, 6 files, +84/−5), after a fresh pnpm install --frozen-lockfile and pnpm --filter @objectstack/spec build (exit 0), against a detached objectui worktree at the .objectui-sha pin 53ded82bf7a494f54e344e19099dbf00854b8694 (the pin recorded at base and at head). Exit codes were captured before any pipe. Nothing below is taken from the PR body or from either implementer report; every figure was re-derived.

① The five things round 2 had to settle

1. ⭐ The BLOCKING finding is closed — on the siblings' shape, with a describe held to the timeline — PASS.

  • Textual: ObjectKanbanPropsSchema :3050 z.array(z.unknown()).optional(), ObjectCalendarPropsSchema :3216 the same, ObjectTimelinePropsSchema :3972 the same. Not a third dialect.
  • Built (dist/ui): the data door on all three faces resolves optional → array → unknown, and its JSON-schema projection hash (z.toJSONSchema, unrepresentable: 'any', io input and io output, description stripped) is fcb554f054135a51 on all three. Firing control for the instrument, in the same run: object-grid.data (a ViewDataSchema union door) hashes 1332670248436c43 — the instrument discriminates a different shape.
  • Behavioural: a five-document battery on data (records array, mixed array, empty array → accept; a string, an object → refuse) reads EQUAL across object-timeline / object-kanban / object-calendar, with both verdicts present so the equality is not vacuous.
  • The .describe() against the renderer at the pin, ObjectTimeline.tsx: «read FIRST as the rail's row source, ahead of the data-scope binding and the fetch» is :256 (props as any).data || boundData || fetchedData; «composed into entries through the same timeline field bindings a fetched row takes» is the effectiveItems mapper :298-393 (rawData.map at :367, title/startDate/endDate/description off the resolved field names); «authoring it suppresses the object query entirely» is the fetch gate :247 (!(props as any).data); «items … wins over this key when both are written» is :299 if (schema.items) return schema.items;. The items describe's new clause («read ahead of every record source, data above included») is the same fact from the other side. ⚠️ This is not a paraphrase of kanban's wording, and this is where a paraphrase would have been wrong: ObjectKanban.tsx:294 reads externalData || boundData || schema.data || fetchedData — the binding BEFORE data — whereas the timeline reads data BEFORE the binding. The describe states the timeline's order. The row header's citation data (:171, :247, :254, :256) verifies: five reads on those four lines, and none elsewhere in the file.

2. The new test leg is a real pin — PASS, re-run here.

Baseline at head, before any mutation: src/ui/component-element-navigation-17987.test.ts 12 passed / 12, exit 0 (12 it( blocks counted in the file). Ablation through scripts/ablation-replace.mjs (WRAP mode, evidence by anchor count and blob hash): rename the timeline data door (data: → dataZZZ: on the one two-line anchor that is unique to this face; anchor x1 → x0; blob d663b3c630d6 → 76eb100aabfd — the pre-mutation blob is the implementer's d663b3c630d6, the mutated blob differs from theirs because the replacement text does): 2 failed / 10 passed, exit 1. The two reds are exactly the two cases that author data — «accepts the key set measured from the renderer at the .objectui-sha pin» and «accepts data, the pre-fetched row source its renderer honours» — and both fail on ["unrecognized_keys"], i.e. the ablated row refused data by name: the round-1 defect re-minted and caught. Every other leg stayed green, the bogus-key control on this same face included. Restore proven: blob == HEAD (d663b3c630d6), git diff HEAD empty, git status --porcelain empty. Independently of the test file, on the built dist: { objectName, timeline: {…}, data: [ … ] } → success: true; the same document plus zzzDefinitelyNotAKey → success: false, one unrecognized_keys issue with keys: ["zzzDefinitelyNotAKey"], whose message names the bogus key and does not contain `data`. The control fires on the face the leg pins, and the refusal names the right key.

3. ⭐ The regression question — the whole row re-derived under BOTH channels — PASS: data was the only miss, second-miss count 0.

Instrument and radius. A sweep over the code lines (comments excluded) of the three non-test sources that answer object-timeline at the pin — plugin-timeline/src/ObjectTimeline.tsx, index.tsx (the registration and the elementDataSourceBlock wrapper), renderer.tsx (the presentational TimelineRenderer the component spreads effectiveSchema into) — plus the two files the wrapper routes the node through, react/src/element-data-source/ElementDataSourceGate.tsx and react/src/hooks/useElementDataSource.ts, for every spelling of a read: schema.X, schema?.X, (schema as any).X, schema[…], props.X, (props as any).X, props[…], destructures = schema / = props / ({ … }: …Props), spreads ...props / ...schema, and the gate's base.X. Firing controls inside the radius: the sweep returns (props as any).data ×5 — the read a schema.*-only sweep misses, which is the round-1 miss — and (schema as any).navigation ×1, a cast read that a bare schema. regex misses; both are in the table below. Known targets outside the radius, by design: the nested reads under timeline.* (metaFields :332-334, the dateField alias :287) — judged by the shared TimelineConfigSchema, round-1 noted, not this row's; and TimelineRenderer's own ...props rest, which ObjectTimeline never feeds (:651 passes schema= alone). Channel plumbing, re-read at the pin rather than assumed: SchemaRenderer.tsx hoists every properties.* key except type/id onto the node (:1057-1066), its strip list is the visibility flags, dataSource, _hidden/_disabled and responsiveStyles (:1452-1479; data is not on it), and createElement spreads every remaining node key as a prop (:1574); elementDataSourceBlock is a Set-marker that reads nothing (core/src/data-scope/element-data-source.ts:382-387); index.tsx:337/351 forwards {...props} whole into ObjectTimeline, which names schema, dataSource, className, onRowClick, onItemClick and keeps the rest as props. So both channels are fed from the same authored node, and a key read on either is authorable.

Every top-level key read, its channel, and the row's disposition (OT = ObjectTimeline.tsx, R = renderer.tsx, G = the gate/hook):

key channel read points at pin 53ded82b row
objectName schema OT :172 :194-204 :215-247 :314 :459 :464; G mapping object declared
timeline schema OT :179 (nested members :263-293, :332, :469) declared, TimelineConfigSchema by reference
filter schema OT :210 :232; G base.filter declared
sort schema OT :211 :233; G base.sort declared
limit schema OT :234 :254; G readLimit(base, 'limit') declared
data props OT :171 (×2) :247 :254 :256 declared — the rework
items schema OT :170 :247 :254 :299 :451 :480; R :1213 declared
variant schema OT :518; R :1212 declared
dateFormat schema (via effectiveSchema) R :1214 declared
rowLabel schema (via effectiveSchema) R :1499 declared
minDate / maxDate schema (via effectiveSchema) R :1436 :1450 :1451 declared
descriptionField schema OT :290 declared
mapping schema OT :263 :288 :290 :291 declared
navigation schema (as any cast) OT :463 declared, NavigationConfigSchema by reference
titleField startDateField dateField endDateField groupByField colorField scale schema OT :263 :288 :289 :292 :293 :469; R :1486 → :72 (via effectiveSchema) refused with the timeline-block prescription — by design, measured: scale → «Write this as a key of the timeline config object instead …»
bind schema OT :188 undeclared; the family-wide data-scope exclusion the row header names
className schema + named prop OT :160 :584 node-level; refused with the node-level prescription («belongs on the component NODE … a sibling of type»)
dataSource schema (G :141) + named prop OT :159; G node-level binding on PageComponentSchema, stripped by SchemaRenderer; refused with the same node-level prescription
onItemClick / onRowClick named props; R :1215 OT :161-162 :465 :595-598 host callbacks JSON cannot carry

The gate's other reads (base.columns, base.viewType, base.pagination) are behind mapping flags object-timeline does not set (index.tsx:330-334: filter, sort, limit: 'limit' only), so they are not read for this block. Reading of the sweep: every top-level key the pinned renderer honours on either channel is declared, or refused with a prescription by design, or node-level/host with the reason stated — nothing is read, honoured and refused by name without a reason. The row is no longer minting a refusal.

4. Nothing else moved — PASS.

The delta is exactly the six files the remedy named: .changeset/17987-element-navigation-declaration.md (the key list), packages/spec/src/ui/component.zod.ts, packages/spec/src/ui/component-element-navigation-17987.test.ts (+44, the one new it and one data line in the key-set document), and three regenerated artefacts (content/docs/references/ui/component.mdx, packages/spec/api-surface-declarations/ui.txt +2, packages/spec/authorable-surface/ui.json +1 ui/ObjectTimelineProps:data). In component.zod.ts the delta at -U0 is three hunks, all inside lines 3886–3975: the row header's data citation (+4), the data door with its docblock (+27), and the items describe (one line, the precedence clause — needed so the two describes do not contradict each other; within the remedy). No hunk touches any other face, so the 48 other faces are byte-identical between the two heads and round-1 item 1's 46-untouched-faces reading carries over unchanged. view.zod.ts: 0 matching paths in the whole PR (git diff --name-only merge-base..head, grep count). The two out-of-surface files round 1 verified (packages/spec/dropped-refinements.baseline.json, docs/audits/2026-07-unknown-key-strictness-ledger.counts.md): 0 of 2 in the delta. Regression sweeps at head: the src/ui spec subset 73 files / 2822 tests passed, exit 0; the full @objectstack/spec suite 500 files / 14612 tests passed, exit 0 (149 s); check:objectui-pin-citations --verify-anchors against the pinned worktree exit 0 (26 asserting citations match 53ded82bf, 7 anchor assertions verified — the new door's citations included). Regenerated artefacts: pnpm --filter @objectstack/spec check:generated from the fresh build reports 15 of 16 up to date — check:docs (the reference page), check:authorable-surface, check:api-surface, check:export-origins, check:declaration-map, check:strictness-ledger among them — and check:api-surface-declarations red locally only; see noted, it is my environment's printer, not the PR's artefact.

5. The PR body and the changeset tell the truth — PASS, one stale figure noted.

Changeset: the object-timeline key list is 15 names in source order — objectName, timeline, filter, sort, limit, data, items, variant, dateFormat, rowLabel, minDate, maxDate, descriptionField, mapping, navigation — and equals Object.keys(shape) of the built row, same 15, same order. PR body: its object-timeline key list is the same 15 in the same order, data included. The parity figure: check:react-declaration-parity in CI's own spelling (MANIFEST="$PWD/sdui.manifest.json" … --baseline react-declaration-parity.baseline.json --strict) exits 0 and reads object-timeline: 2 declared by both, 13 spec-only, 0 registry-only — the patched figure reproduces; no new divergence vs the baseline. The seat's <sub> note in the body records both edits accurately. The changeset's «and refuses everything else» is now a true sentence by item 3.

noted (not blocking)

  • check:api-surface-declarations reds in this environment and only here. Regenerating api-surface-declarations/ui.txt from the fresh dist here gives 179 insertions / 179 deletions, every one of them an enum-member reordering inside a z.ZodEnum<{…}> block (inline/sidebar, asc/desc, note/approval/event …), reported as «0 removed, 0 added, 35 reshaped»; no hunk touches the ObjectTimelinePropsSchema data line, and the delta's own +2 lines to that file are exactly the two data: z.ZodOptional<z.ZodArray<z.ZodUnknown>>; lines. Two consecutive local regenerations are byte-identical to each other (23977e33f1) and differ from the committed 44957c6290 only by that ordering, so the difference is between printers, not between runs; and the PR's own CI ran the same gate on the same committed file — TypeScript Type Check on this head concluded success at 2026-09-20T00:53:13Z, beside Lint & Repo Gates, Build Core, all six Test Core shards, Dogfood Regression Gate, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard, all success. Restored after each regeneration (blob == HEAD, porcelain empty). Which toolchain difference reorders the members is deliberately not established here. No action on the PR.
  • The PR body still says «(11 tests)» for the pin file, and its reverse-verification table (5 failed / 6 passed, 9 failed / 2 passed, blob 9b38bfa56e57) and the «500 files / 14611 tests» line are readings of the round-1 tree that the body no longer labels as such. The tree has 12; the seat's patch covered the key list and the parity figure, as scoped. A one-word edit by the body's owner, not a contract matter.
  • The row header's «deliberately NOT declared» list names bind, className and the callbacks but not dataSource, which the gate reads (useElementDataSource.ts:141) and which the row refuses with the correct node-level prescription anyway. Cosmetic; the refusal already teaches the author the truth.
  • Carried over from round 1, unchanged and outside this row: timeline.metaFields and the timeline.dateField alias are read at the pin and refused by the shared TimelineConfigSchema; the three faces that declare navigation as z.unknown(). Still uncarded as far as this review can see.

② Semver level

minor on @objectstack/spec — unchanged from round 1 and still right: the rework adds one more optional key to a strict row that landed in the same PR (a further widening under Clause-②: yes (widening)); nothing renamed, retyped, defaulted differently or removed.

③ Boundary flags

  • Governed surfaces: none touched (packages/spec/**, content/docs/references/**, .changeset/** only in the delta; the whole PR adds docs/audits/**).
  • view.zod.ts / ListViewSchema.navigation: still untouched (item 4).
  • .objectui-sha: unchanged at base and head; nothing removed or renamed, so the Console Pin Gate has nothing to break.
  • Downstream unlock stays consumer-installable, as the PR states; not this merge.

Implemented-by: claude/issue-17987-element-navigation-declaration
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee50b4cd13e07a2d3327e78d5f98aefd30ed85ab


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 20, 2026 01:27
@os-steve
os-steve added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit e233db9 Sep 20, 2026
58 checks passed
@os-steve
os-steve deleted the claude/issue-17987-element-navigation-declaration branch September 20, 2026 01:57
os-bill pushed a commit that referenced this pull request Sep 20, 2026
Sync with origin/main through scripts/pm/os-regen-merge.sh, whose step 2
restored main's side of the four os-regen artifacts both branches moved
(api-surface/ui.json, api-surface-declarations/ui.txt, authorable-surface/ui.json,
export-origins/ui.json) — the driver had merged them exit 0 while silently
keeping one side. Regenerated from the merged tree after a real build, so both
sides' entries are present: #19219's element-level `navigation` rows and its
ObjectTimelineProps block, and this branch's three `limit` rows plus
DEFAULT_VIEW_ROW_LIMIT and KanbanConfigParsed. gen:docs adds the timeline row to
the component reference page.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
os-bill pushed a commit that referenced this pull request Sep 20, 2026
…ed tree

Baseline drift, not a code change. `origin/main`'s #19219 moved
`ComponentPropsMap` and `ObjectTimelinePropsSchema` after this branch forked,
and `api-surface-declarations/**` is a `merge=os-regen` path — so the merge
produced a `ui.txt` that was current for neither side, which is what turned
`Check @objectstack/spec declaration text` red.

Regenerated from a real build of the merged tree (34/34 declaration files
emitted; ⛔ no `OS_SKIP_DTS`), via `scripts/pm/os-regen-merge.sh`.

BOTH SIDES asserted present afterwards, by quoted-exact name against the INDEX
blob rather than the worktree, with a dark control reading 0 files:
this branch's `find(object: string, query: EngineQueryOptions)` and
`ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION`; #19219's `ObjectTimelineProps`,
`ObjectTimelinePropsSchema` and `ComponentPropsMap`. Note the two `navigation`
keys that branch also added live in `authorable-surface/ui.json` and in NO
declaration file — a grep scoped to `api-surface-declarations/` reads 0 for
them out of range, not out of loss, so the assertion ran over the whole tree
and printed paths.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
os-bill pushed a commit that referenced this pull request Sep 20, 2026
…ged tree

Baseline drift, not a code change — the fourth sync lap on this branch. Main's
#19226 and #19235 moved `packages/spec/api-surface-declarations/{data,root,
system,ui}.txt`, and that directory is a `merge=os-regen` path, so the merge
produced four files current for neither side.

Regenerated from a real build of the merged tree (34/34 declaration files
emitted; ⛔ no `OS_SKIP_DTS`), via `scripts/pm/os-regen-merge.sh`, with
`MERGE_HEAD` confirmed absent first — the build opens with `gen:schema`, and
running that in MERGE state is the anchor-rollback trap.

⚠️ The `MM` grade was live here and was read on purpose. After regenerating,
the index held main's side (803/535) while the worktree held the regeneration
(323/4); a bare `git commit` would have landed the index. `git add -A` first,
then `git diff --cached` re-read as the 323/4 it should be, and every one of the
four index blobs hash-matches its worktree file.

BOTH SIDES asserted by quoted-exact name over the WHOLE TREE with paths printed,
then again against the index blobs, with a dark control reading 0 files:
this branch's facade signature and prescription constant; #19226's
`DEFAULT_VIEW_ROW_LIMIT`, `KanbanConfigParsed` and the three
`ui/{Gallery,Kanban,Timeline}Config:limit` keys; #19235's
`RecordRelatedListProps.columns[number]` and `z.array(ListColumnSchema)`; and
#19219's `ObjectTimelinePropsSchema` carried forward. Note the three `limit`
keys live ONLY in `authorable-surface/ui.json` and `authorable-defaults/ui.json`
and the related-list row ONLY in `content/docs/references/ui/component.mdx` —
a grep scoped to the declaration files reads 0 for them out of range, not loss.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…hipped prescriptions were denying a door that exists (objectstack-ai#19234)

Fixes objectstack-ai#17487

Clause-②: no

## The defect, and its direction

Three shipped, customer-facing prescriptions in `@objectstack/spec`
stated in the present tense that the runtime confirmation door had not
shipped. It has: `actionConfirmationRefusal` is called pre-dispatch by
`invokeBusinessAction` in `@objectstack/runtime`, and the MCP
`run_action` tool grew the `confirm` member in the same change (the card
behind it, objectstack-ai#15942, is done — `state_reason=completed`; its changeset
`action-confirmation-gate-enforced` is still pending, so the door is on
`main` and not yet released).

So the published text denied a door that exists, and it failed in the
dangerous direction: an author who reads it concludes the safety flag
stops nothing, and either arranges a human in the loop some other way or
stops setting the flag — losing the gate at the moment it starts
working. That is the ADR-0049 false-compliance class with the sign
flipped.

## Re-derivation — all three sites read on today's `origin/main`

Triage's unblock comment verified site 1 only and said the other two
were unmeasured. All three were re-read at merge base `805811e0d`.

| # | Path | Current text | Verdict |
|---|---|---|---|
| 1 | `packages/spec/src/ai/tool.zod.ts` —
`TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation` | "the declaration is
the contract, not yet the behaviour — the runtime door that performs the
refusal ships separately, and until it does, setting the flag does NOT
stop an unconfirmed call. Do not try to verify the gate by invoking the
operation without the member: until that door lands, such a call simply
RUNS." | **FALSE today** |
| 2 |
`packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts`
— `replacement` | "The refusal is DECLARED, not yet performed — the
runtime door lands in objectstack-ai#15942, so until then the flag stops nothing on
its own and the human in the loop is still yours to arrange" | **FALSE
today** |
| 3 | the same file — `acceptanceCriteria` | "Do NOT try to 'prove the
gate' by invoking the operation without the confirmation member: the
runtime door that refuses lands in objectstack-ai#15942, so before that ships the call
is not refused, it RUNS the destructive operation." | **FALSE today** |

**Correction to the card's count of the carriers.** The card names the
`spec-changes` entry, the upgrade guide and the `os migrate meta`
projection as if they were separate sites. They are not: all three are
projections of the **one** ADR-0087 D3 entry file above. The measurement
is therefore **three false prescriptions living in two source files**,
plus three generated artefacts that carry them
(`src/migrations/registry.ts`, `spec-changes.json`,
`docs/protocol-upgrade-guide.md`), all regenerated here by
`check:generated --fix`.

Sweep radius for "is that all of them": eleven denial phrasings grepped
repo-wide (`not yet the behaviour`, `ships separately`, `not yet
performed`, `stops nothing`, `simply RUNS`, `until it does`, `until that
door`, `door lands`, `yours to arrange`, `nothing server-side`, `no
pause`), with `requiresConfirmation` lighting 10 files under
`packages/spec/src` as the positive control. Two adjacent texts were
read and left alone as **NOT A DEFECT**:
`packages/spec/src/contracts/ai-service.ts` already states the gate in
normative present tense, and `content/docs/ai/tools.mdx` says the
retired **tool**-level key "returns only together with its enforcement",
which is still true — the tool key has not returned. Two further
readings are recorded under *Acceptance notes*.

## What the prose says now, and what holds it there

Each prescription now states the refusal in the present tense **with the
door's bounds**, because an unbounded "the platform refuses unconfirmed
calls" is this same defect in the other direction. Read off the door's
own docblock and its shipped changeset, never inferred:

- the refusal is `ACTION_CONFIRMATION_REQUIRED`, 428, naming the action
and the member `confirm: true`;
- a GATE, not a queue — nothing is parked, and a refused call did not
run: the gate sits before `loadActionSubjectRecord`, so no record is
read and none written;
- the enforced set is the doors that enforce the author's `ai.exposed`
opt-in — today the action door reached from MCP `run_action`. REST
`/actions` is **not** `ai.exposed`-gated and sits outside the gate, so
an API-key agent on that route still needs its own human;
- only the author's declared `ai.requiresConfirmation: true` refuses,
and only the boolean `true` confirms; the wider `list_actions` heuristic
advises and never refuses;
- `confirm: true` is an unverifiable caller claim: the gate makes
forgetting loud, it does not prove a human.

`packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts`
is the tie that was missing the first time — the prose was never bound
to the function it describes, which is how it rotted. It reads the three
shipped strings **and** the runtime door, and fails in both directions.

**No pin was moved.**
`ui/action-requires-confirmation-docblock.pin.test.ts` was read: it
anchors on the `ai.requiresConfirmation` JSDoc in `ui/action.zod.ts` and
on `actionLooksDestructive`, neither of which this diff touches, so it
covers none of the three sites and stays as it is.

## Clause-②: no — the accept set did not move

`check:authorable-surface` and `check:api-surface` are green with
**zero** diff under `packages/spec/authorable-surface/` and
`packages/spec/api-surface/`. The pin's last case feeds the same
authored metadata in before and after: `tool.requiresConfirmation` still
refused, a minimal tool still accepted, `action.ai.requiresConfirmation`
still accepted for both `true` and `false`. What moved is string content
inside `dist` and `spec-changes.json`, which is why a `patch` changeset
is owed and present.

## Tests, and the reverse verification

`pnpm --filter @objectstack/spec test` — 499 files / 14614 tests passed.
`test:repo` — 34 files / 580 tests passed. `typecheck` — clean. New pin:
8/8.

Three ablation legs, each mutated on disk through
`scripts/ablation-replace.mjs` (anchor hit declared, blob hash proven to
move), direction predicted before the run, restored and proven by blob
hash against `HEAD` with `git diff HEAD` empty:

| leg | mutation | predicted | observed |
|---|---|---|---|
| 1 | re-insert `The refusal is DECLARED, not yet performed` into the D3
entry's `replacement` | RED on "no shipped prescription denies the
refusal" | RED, naming the replacement carrier |
| 2 | rename the gate call inside `invokeBusinessAction` | RED on "the
AI-facing door still calls the gate pre-dispatch" | RED |
| 3 | make the REST `/actions` door name the gate | RED on the
over-claim guard | RED |

Leg 3's **first attempt was a no-op** and is reported as such: the
replacement text still contained the anchor, so `ablation-replace`
refused (anchor drop 0, not the declared 1) and nothing ran. It was
re-anchored and re-run; the reading above is the re-run.

## Gates

All 85 commands derived by `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` for this diff were run locally
and exit 0, exit codes captured before any pipe. Eight first returned a
stale-`dist` or `PREREQUISITE NOT MET` result (exit 1 / exit 3 — not
measured, not findings); they were re-run green after `pnpm --filter
@objectstack/spec build` and a full `turbo run build` closure. `pnpm
lint` (`eslint . --no-inline-config`, whole repo, no narrowing) exits 0
at `HEAD`. CI still owns its own farm: the five path-scheduled CI jobs,
the 11 wide-population families and the artifact rosters are outside
that 85 and are NOT MEASURED here.

## Acceptance notes

Two readings taken while re-deriving, both **out of scope for this
card** and neither edited here:

1. `packages/spec/docs/MCP_GUIDE.md` (around the "Side Effects" section)
tells an author to gate side effects with "`ai.requiresConfirmation` on
the underlying **action** (+ the HITL approval queue)" and then warns,
in the adjacent block, that "nothing server-side pauses on it". The
warning is correctly scoped to the MCP capability descriptor in that
page's examples and is true of it; but the approval-queue requirement
now overstates what the action-level flag needs, and the two paragraphs
read together in the card's own dangerous direction. Not in the declared
file surface. Reported for filing with dedupe words: `MCP_GUIDE`,
`requiresConfirmation`, `HITL approval queue`, `nothing server-side
pauses`, `confirmation gate`.
2. `content/docs/ai/actions-as-tools.mdx` — the "Human-in-the-loop
approval" section still says that on the open MCP path "the approval
step lives at the protocol boundary" (client-side prompting), and the
numbered open-MCP action-gate list enumerates five gates without the
confirmation gate that now sits between the param contract and the
subject-record load. An omission against a contract that
`@objectstack/spec/contracts` declares. Reported for filing with dedupe
words: `actions-as-tools`, `human-in-the-loop`, `protocol boundary`,
`run_action`, `confirmation gate`.

Noted, not filed: `packages/spec/src/api/error-code-ledger.zod.ts` says
of the `ACTION_CONFIRMATION_REQUIRED` row that "the door will assert
this exact string by value" — a forward tense about something that is
now true. It misleads nobody about the gate and it is provenance prose
about the row's split registration, not a prescription. Successor: the
next change that touches that ledger row.

## Occupancy

Re-scanned at 2026-09-20T01:52Z over all 21 open PRs, with PR objectstack-ai#17076
(639 files) fully paged so no path is under-read.
`packages/spec/src/ai/tool.zod.ts`, the D3 entry, `spec-changes.json`,
`docs/protocol-upgrade-guide.md`, `vitest.repo-tests.json` and
`src/ai/tool.test.ts` all read FREE. Firing controls in the same scan:
`packages/spec/src/ui/component.zod.ts` HELD by objectstack-ai#19219,
`packages/spec/src/ui/view.test.ts` HELD by objectstack-ai#19226; dark control (a
nonexistent path) reads FREE. One reading to flag:
`packages/spec/src/migrations/registry.ts` reads HELD by objectstack-ai#19223, objectstack-ai#19090
and objectstack-ai#18319 — it is a generated, `merge=os-regen` artefact and none of
those three touches the D3 entry this diff edits, so the contention is
the one the regen driver exists for rather than two hands on the same
prose.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants