Skip to content

feat(spec)!: object-metric drillDown and compareTo take the shape the tile reads; object-grid columns exits its hold (#21464, stage 5) - #21673

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21464-s-metric-rest
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21464-s-metric-rest

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #21464
Clause-②: yes (narrowing)

What this does

Stage 5 of the ComponentPropsMap z.unknown() close-out: the S-metric remainder and the exit of the object-grid columns hold, per the claim 5975220557, the seat's S-metric answer 5972917488 (§1 drillDown: B; §2 compareTo: B), the staging answer 5963787404, the holds' carrier answer 5969669440, stage 4's landing 5973565761 and domain:devx's pointer 5974144504. Three narrowings, one ADR-0087 D3 entry each. Read points are at the .objectui-sha pin ab1879721595 (this repo's pin), under packages/plugin-dashboard/src/ or packages/plugin-grid/src/ unless named.

member was now read point at ab1879721595
object-metric compareTo z.unknown() strict { kind } (module-private ObjectMetricCompareToSchema): kind is DashboardWidgetSchema.shape.compareTo's own kind by reference; dimension refused by name with the prescription ObjectMetricWidget.tsx:522-523 (shiftFilterByCompareTo) and :675 (compareToTrendLabelKey); both dispatch on kind === 'previousYear' alone (objectui core/src/utils/compare-to.ts:104, :128), and the file's header records dimension as never read on this path (:28-33)
object-metric drillDown z.unknown() strict { enabled?, title?, target?, columns?, maxRows?, report? } (module-private ObjectMetricDrillDownSchema): the first five are ChartDrillDownSchema.shape's members by reference; filter and mode refused by name with objectui's prescriptions; report HELD at z.unknown() ObjectMetricWidget.tsx:696 (isDrillEnabled), :708 (resolveDrillTitle against an empty click event), :732-744 (the shared DrillDownDrawer: target, columns, maxRows, report); objectui's own ObjectMetricDrillDownConfig refuses filter and mode by name (types/src/data-display.ts:2628)
object-grid columns z.array(z.unknown()), held at stage 2 ListViewSchema.shape.columns by reference (the reference stage 2 held it from) ObjectGrid.tsx:819 (normalizeColumns), :3367 / :3451 (the draw path keeps only an entry with a string field, reading ListColumn's own members); groupValueFormatter (:2979) now reads the column's type alone, typed as ListColumn (:3004-3007), and the labels from objectDefField?.options only (:3010)

enabled and title keep the chart drill-down's defs (_zod.def identity, pinned) with describes of their own, because the chart's speak of a clicked segment and its ${event.*} tokens, and a metric tile has no click context. No new member carries a default or a transform, so ObjectMetricPropsParsed still differs from the authored state on filter alone; a parsed grid column's prefix.type carries the list view's 'text' default, as stage 2 originally recorded. No new export: both new schemas are module-private.

The holds, re-read at ab1879721595

  • object-grid columns: exit condition met. Stage 2's records held the member on one read (colOverride?.options || objectDefField?.options at 89cad75d55), and the writer test (5966636964) held it only if a renderer path at the pin reads editable or options off an authored column. objectui b0bf413 (finding(plugin-grid): the grouped grid's header labels read columns[].options, a key ListColumnSchema refuses, blocking the spec from typing object-grid columns objectui#11544) retired the options read, and no read takes editable (or reference) off an authored column: the emitted columns are built key by key from ListColumn members (:3676-3702), and every object spread at :4384, :4483, :4628, :4728 is of an EMITTED column. The three records that said the grid reads columns[].options are corrected: the docblock in component.zod.ts, the enumeration pin's held line (removed) and the list-family pin's header. One finding beside the exit, see Acceptance notes: the footer summary still reads four undeclared column keys.
  • object-metric drillDown.report: stays held, as ruled. Its docblock is re-anchored: the drawer draws a dataset-bound report when isDatasetBoundReport holds (DrillDownDrawer.tsx:92, used at :115; objectui#11506, 8366acc), and objectui#11517 (9ed8d0f) refuses the { name } arm. Expressed in the house shape: the enumeration pin's walk reaches it as object-metric drillDown.report, a staged('objectui-held', …) ledger line, and the object-metric stage leaves STAGES (its four members are typed). No fork was needed.

The census

A writer is a value written on the block: a page-component node (an object literal naming the type, a literal annotated or asserted with the block's type, a direct parse through the row), the block's React component with the member as a prop or inside an object-literal schema, or the argument of a local helper that mounts one, with positional helper parameters resolved at every call site. Values resolve through same-file constants. Instrument: a TypeScript-AST walk over every .ts, .tsx, .js, .jsx, .mjs, .cjs, .json, .yaml, .md and .mdx file (fenced code in documents parsed too). Each static value was parsed through the row at this branch's source, and the grid's refused set was cross-checked against ListViewSchema.shape.columns directly (identical).

corpus compareTo drillDown object-grid columns
objectstack 6ec54f00ba (examples/, packages/ with packages/apps/, content/, skills/, apps/, docs/) 0 0 5 · 5 static · 5 parse
objectui ab1879721595 (whole tree, git archive of the pin) 5 · 5 (3 distinct) · 4 parse · 1 refused 26 · 25 static (13) · 23 parse · 2 refused · 1 not static 362 · 353 static (147) · 312 parse · 41 refused · 9 not static

objectstack writers. The showcase's two grids (examples/app-showcase/src/ui/pages/my-work.page.ts:65, command-center.page.ts:172) and three test copies (packages/lint/src/validate-component-props.test.ts:553, :577, packages/spec/src/ui/component.test.ts:3433): all field-name strings, all parse, all drawn. origin/main moved four commits since the base (to fea67065a3); none touches packages/spec, and none adds a line naming object-grid, object-metric, drillDown or compareTo.

objectui writers, under the writer test. Every value is a test fixture or a run-time hand-off. Each refused value:

refused value where at the pin drawn or probe
compareTo: { kind: 'previousYear', dimension: 'close_date' } plugin-dashboard/src/__tests__/ObjectMetricWidget.compareTo.test.tsx:109 probe: "carries a dimension without letting it touch the query"
drillDown: { enabled: true, filter: { stage: 'won' } } and { enabled: true, mode: 'record' } plugin-dashboard/src/__tests__/ObjectMetricWidget.drillDownRefusal-9002.test.tsx:56, :58 compile-time refusal probes (@ts-expect-error)
17 columns keyed accessorKey / header columnDeclaredSpellingOnly ×4, columnSpellingDiagnosticRender ×9, ObjectGrid.malformedFilter-9050 ×3, authoredColumnsFls-6799:409 not drawn: resolvesToDataColumn needs field; the spelling diagnostic names them
5 columns keyed name projectionFls-6898:206, authoredColumnsFls-6799:333, columnIdentity ×3 not drawn by that key: the draw path reads field (the identity fold reads name for the projection and field security only, field winning)
14 columns carrying editable: false gridDependentValues-7165, lookupPickerKeys-7154 ×5, relationalMetaCopySet-7166 ×4, inlineEditExitMode ×2, inlineEditPersistence, inlineEditLookupRepro the column draws; no read takes editable off an authored column
1 column carrying reference ObjectGrid.linkCellAnchor.test.tsx:252 the column draws; relational metadata comes from the object field (applyRelationalMeta), and the test asserts the cell stays plain
2 columns carrying options gridGroupingMembers-8071.test.tsx:293, :312 probes: the tests assert the column options are NOT read
1 column with no field serverGroupedSelectIdentity-11105.test.tsx:162 not drawn (resolvesToDataColumn)
columns: 5 types/src/__tests__/element-data-source-objectname-waiver-11117.test.ts:154 a refusal probe through objectui's own mirror

The not-static values: one drillDown it.each over two report probes (both parse: report is held open); for columns, the run-time hand-offs in plugin-view/src/ObjectView.tsx:2487, plugin-designer/src/ObjectManager.tsx:116 and FieldDesigner.tsx:188 (the designers' lists are { field, label, width }), and six test lists built from { field, label[, type] } entries, which parse by construction. Stage 2 counted 40 refused columns values with a different instrument; the 41st here, the reference column, was present at 89cad75d55 too.

Changes

  • packages/spec/src/ui/component.zod.ts: ObjectMetricDrillDownSchema and ObjectMetricCompareToSchema (module-private) with their docblocks and prescriptions; the two members; columns back to ListViewSchema.shape.columns with the exit docblock; the grid row and ObjectGridPropsParsed docblocks; imports of ChartDrillDownSchema (beside ChartGroupBySchema) and DashboardWidgetSchema (dashboard.zod.ts imports nothing that reaches component.zod.ts; the eager gen:schema build is green).
  • packages/spec/src/ui/component-props-unknown-members.pin.test.ts: the drillDown, compareTo and columns[] lines leave the ledger; drillDown.report joins as objectui-held; the object-metric stage leaves STAGES, whose objectui-held text now names the drill report.
  • packages/spec/src/ui/component-metric-family-typed-members.pin.test.ts: §1 seven new byte-identical parses (objectui's drill and comparison writers), §2 ten new refusals by code and path plus three prescription checks, §3 def identity for the five drill members and kind, and each shape's exact member set, §4 the two new D3 ids.
  • packages/spec/src/ui/component-list-family-typed-members.pin.test.ts: the columns cases stage 2's fix round removed come back (§1 two parses and the prefix default, §2 nine union refusals including options, reference and a footer precision hint, §3 def identity), and the header no longer says the grid reads a column's options.
  • packages/spec/src/migrations/entries/semantic/18.ui-object-metric-compare-to-typed.ts, 18.ui-object-metric-drill-down-typed.ts, 18.ui-object-grid-columns-typed.ts (new) and packages/spec/src/migrations/registry.ts: three hand-written step-18 rationale fragments at orders 71, 72 and 73 (70, feat(spec)!: retire the object-master-detail-form detail entry sortField — the console derives the line-position field from the child object (#21589) #21632's, is the last taken on main), and the semantic region regenerated by gen:migration-registry. No D2 conversion: nothing stored needs rewriting, since page-component properties is not parsed on the save or load path. No RETIRED_KEYS_BY_MAJOR row: the refused keys are nested in member shapes, not top-level row keys.
  • Regenerated: content/docs/references/ui/component.mdx (gen:docs) and docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md (gen:strictness-ledger: ui/ 194 → 196 sites, +2 strict).
  • .changeset/21464-component-props-metric-rest-and-grid-columns-typed.md: @objectstack/spec minor, BREAKING banner, the Clause-② line, the FROM → TO table, the census, and the ADR-0087 marker registering the three ids.

Measurements

All on the head a10f94d7c4 (base 6ec54f00ba; 11 files, +765 / −119, 884 changed lines, under the 5000 threshold). Heavy runs went through scripts/pm/os-verify-lock.sh, and every exit code was written to a file before it was read.

  • pnpm --filter @objectstack/spec test: exit 0 — Test Files 609 passed (609); Tests 18095 passed, 1 todo.
  • pnpm --filter @objectstack/spec typecheck: exit 0 — check:test-typecheck: OK (52 files / 246 errors / 135 signatures held, unmoved); tsc -p tsconfig.test.json --listFilesOnly names all three pins.
  • pnpm --filter @objectstack/spec build and check:generated --fix: the build is green (the eager gen:schema pass included); --fix regenerated only what it proved stale (gen:docs, gen:strictness-ledger), and the derived check:generated then exits 0.
  • Consumers (the row's import side and the one example that authors these blocks): pnpm --filter @objectstack/lint exec vitest run src/validate-component-props.test.ts — 51 passed (after building lint's closure; the first try answered Failed to resolve entry for package "@objectstack/formula", an unbuilt dependency, not a measurement); pnpm --filter @objectstack/lint test — Test Files 119 passed, Tests 5627 passed; pnpm --filter @objectstack/example-showcase validate (objectstack validate) — "Validation passed".
  • The public door, lit both ways (validateComponentProps from @objectstack/lint over the built spec, one block per page): a drill with enabled / target / columns / maxRows, a held report, compareTo: { kind: 'previousYear' }, the showcase's string columns and a { field, label, width } column report nothing; a drill filter reports component-props-unknown-key at properties.drillDown.filter, a comparison dimension at properties.compareTo.dimension, a bare compareTo: 'previousYear' component-props-invalid at properties.compareTo, an accessorKey column component-props-invalid at properties.columns, and a column options component-props-unknown-key.
  • Ablation, one leg per narrowing, on the committed tree (node scripts/ablation-replace.mjs in wrap mode inside a script with its own EXIT INT TERM restore trap on absolute paths, HEAD-blob comparison, an empty hash read as failure). Each leg reverts one member to its former z.unknown() form and runs the three pins (175 tests):
    • compareTo → z.unknown().optional(): anchor x1 → x0, blob b3bba43a35 → 4ef63a8559. Red: 8 failed, 167 passed. The enumeration pin's §1 received exactly [ "object-metric compareTo" ] and its census-equals-ledger control read 92 against 91; the companion pin failed its four comparison refusals, the dimension prescription and the kind def identity.
    • drillDown → z.unknown().optional(): blob → 51d55b8e7b. Red: 12 failed, 163 passed. §1 received [ "object-metric drillDown" ], and its second half [ "object-metric drillDown.report" ] (a ledger line outliving its member); the companion pin failed its six drill refusals, the two prescriptions and both §3 cases.
    • columns → z.array(z.unknown()).optional(): blob → 8f09c64675. Red: 13 failed, 162 passed. §1 received [ "object-grid columns[]" ], 92 against 91; the list-family pin failed the prefix-default parse, its nine columns union refusals and the def identity.
    • Restore after every leg and in the trap: blob b3bba43a35 equals the HEAD blob and git diff HEAD is empty. Green control after the three legs: 175 passed (175). The pins import ./component.zod from source, so no build or dist preflight sits between mutation and run.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) — 114 commands; --ran — "Run reconciliation — 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN", exit 0, all 114 exit 0. Six first answered PREREQUISITE NOT MET (exit 3) in the fresh worktree (@objectstack/lint check:doc-formula-expressions and check:doc-security-posture, spec check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure); after one turbo run build (72 tasks, 71 cached) each exits 0, and those reruns are what the reconciliation records.
  • Changeset gates with this body as the pull_request payload: check-changeset-no-major exit 0 — "LEVEL AXIS: this PR declares clause-② yes (narrowing), and no package whose packages/**/src/** it moves is graded patch"; check-adr-0087-registration exit 0 — "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"; check-empty-changeset exit 0.
  • Narrowed lint: eslint --no-inline-config --format json over the 8 changed TypeScript files — 8 files, 0 errors, 0 warnings. Population from eslint's own --print-config (the 8 resolve a config; the changeset, component.mdx and the strictness counts page resolve undefined); count from the JSON output; invariance: eslint.config.mjs never enables type-aware linting (its own text, about lines 326-328), so this diff cannot move a verdict on an untouched file. The full pnpm lint is CI's.
  • NOT MEASURED: the Console Pin Gate, the Dogfood Regression Gate and the full pnpm lint, reason: CI-owned; objectui was read at the pin, not built or run against this spec.

Acceptance notes

  • objectui's grid footer still reads four column keys ListColumn does not declare. plugin-grid/src/useColumnSummary.ts:558-568 at the pin formats a column's summary with (col as any).currency, .defaultCurrency, .precision and .scale ahead of the field's (the same file reads currencyConfig and max off the field only, saying a column-level read "would be a second, undeclared spelling"). The read was there at 89cad75d55 too. No measured writer authors one on an object-grid column, so under the writer test it holds nothing, and a list view's columns (the same entry) already refuse all four; with this PR the grid row refuses them as well (pinned: the precision case in the list-family pin). Reported for the seat as an objectui finding, the shape finding(plugin-grid): the grouped grid's header labels read columns[].options, a key ListColumnSchema refuses, blocking the spec from typing object-grid columns objectui#11544 had.
  • The drill report's contract may be cheaper than the hold suggests. At the pin objectui types DrillDownConfig.report as this spec's own ReportSchema author input (types/src/data-display.ts:23-24, :2592, SpecReportInput), and the drawer applies the spec report's runtimeFilter. So the S-objectui-held step for report may reduce to the spec declaring the member by reference to ReportSchema. Not done here, as ruled; raised for the seat.
  • objectui's next @objectstack/spec bump: its block schemas take these rows by reference (ObjectMetricBlockSchema, ObjectGridBlockSchema, through propsBag), but no objectui fixture that authors a refused value parses through them: the refused metric values mount the widget directly, and the refused grid columns mount ObjectGrid directly. objectui source does not import the narrowed ObjectMetricProps / ObjectGridProps types. Not built here: the Console Pin Gate builds objectui at the pin against main, and its filter does not list packages/spec/src/**.
  • check-widening-tells --declaration no exits 4 with seven T1 tells, every one a key inside a former z.unknown() bag (the drill's six members, compareTo.kind), the shape the gate rules a true refusal; --declaration yes exits 0. The claim, this body and the changeset declare yes (narrowing).

Generated by Claude Code

claude added 4 commits October 4, 2026 01:26
… tile reads; object-grid columns exits its hold (#21464, stage 5) — WIP schemas and pins

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…8 fragments and changeset (#21464, stage 5)

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…ss counts; the metric drill's enabled describe (#21464, stage 5)

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…abled describe (#21464, stage 5)

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

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 10 documentable anchor(s).

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

  • content/docs/data-modeling/analytics.mdx (via close_date (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/formulas.mdx (via close_date (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/seed-data.mdx (via close_date (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectql/query-syntax.mdx (via close_date (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectui/concept.mdx (via close_date (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectui/layout-dsl.mdx (via close_date (literal, a string literal in a comment on a changed line))
  • content/docs/ui/dashboards.mdx (via close_date (literal, a string literal in a comment on a changed line), previousPeriod (literal, a string literal in ObjectMetricPropsSchema), previousYear (literal, a string literal in ObjectMetricPropsSchema; a string literal in a comment on a changed line))
  • content/docs/ui/pages.mdx (via close_date (literal, a string literal in a comment on a changed line))

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

  • content/docs/releases/v16.mdx (via close_date (literal, a string literal in a comment on a changed line))

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
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 138 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 55e6f14f8d87004c50b0f35eaafba36ac23cf32b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b4bae5b2f79f04319bb83f5328328fb46016eeae — the merge of head a10f94d7c482d8759e4d687341f778682ac7846c into base 55e6f14f8d87004c50b0f35eaafba36ac23cf32b, 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 b4bae5b2f79f04319bb83f5328328fb46016eeae && git checkout b4bae5b2f79f04319bb83f5328328fb46016eeae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55e6f14f8d87004c50b0f35eaafba36ac23cf32b a10f94d7c482d8759e4d687341f778682ac7846c && git checkout -B drift-repro 55e6f14f8d87004c50b0f35eaafba36ac23cf32b && git merge --no-ff a10f94d7c482d8759e4d687341f778682ac7846c

node scripts/docs-audit/affected-docs.mjs --json 55e6f14f8d87004c50b0f35eaafba36ac23cf32b

⚠️ 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 55e6f14f8d87004c50b0f35eaafba36ac23cf32b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 02:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 02:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 72f3c74 Oct 4, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21464-s-metric-rest branch October 4, 2026 03:23
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ete_record node whose static objectName is a stored-metadata table, with the runtime's prescription (objectstack-ai#21654) (objectstack-ai#21687)

Fixes objectstack-ai#21654
Clause-②: yes (narrowing)

The save-time half of objectstack-ai#21624, route A as ruled (triage `5972908953`, the
`domain:services` seat's answer `5974847898`). objectstack-ai#21624 remains open until
both halves have landed; its seat owns that card. The run-time half is
PR objectstack-ai#21649 (`f40bb3217f`).

## What changes

- **The refusal.** `flowNodeConfigRefusals`
(`packages/spec/src/automation/flow-node-config-refusals.ts`), the one
judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses
first) and `objectstack validate` share, gains a third arm beside the
executor-contract arm and the decision arm. A `create_record`,
`update_record` or `delete_record` node whose `config.objectName` is a
**static string** naming a stored-metadata table, judged by
`isStoredMetadataBodyObject` by exact name, is refused at
`nodes.N.config.objectName` (any depth, ADR-0031 regions included). The
message names the node type and the table, uses the run-time refusal's
verbs (create a record in / update / delete from) and ends on the
run-time prescription.
- **A new closed-set code.** `write-node-stored-metadata-target` joins
`FLOW_SLOT_REFUSAL_CODES` with `params: { nodeType, objectName }`
(`flow-node-expression-paths.ts`: the code table the judge's return type
requires).
- **Out of reach on purpose.** A dynamic `objectName` (a `{token}`
template, an expression envelope) is not judged at save: the run judges
the name it hands the data engine. A `get_record` node is not judged by
this arm: a read is not a write.
- **One prescription sentence.** The hook refusal's private
`STORED_METADATA_BODY_PRESCRIPTION` moved, byte for byte, to the
import-free leaf `kernel/stored-metadata-body-objects.ts` as an export.
`data/hook.zod.ts` and the new flow arm both import it, and
`kernel/metadata-type-redaction.ts` re-exports it beside the family set,
so `@objectstack/spec/kernel` publishes it (`api-surface/kernel.json`,
`export-origins/kernel.json` regenerated). In `hook.zod.ts` only the
import line, the constant and the three comment lines describing it
changed. The `handler` doc region that objectstack-ai#21604 holds is untouched.
- **The ADR-0087 kit.** The D3 semantic entry
`entries/semantic/18.flow-write-node-stored-metadata-target-refused.ts`
(its prescription names the metadata protocol), its step-18 rationale
fragment at order 74, the next free order on `main` at `7d0781482d` (71
to 73 are taken), and the regenerated `registry.ts` regions. No
tombstone (no key is removed) and no D2 conversion (a refused node
carries no intent a rewrite could keep). One BREAKING
`@objectstack/spec` changeset with the `registered` disposition and the
`Clause-②` line.

## Wording, set against the run-time refusal

- Run time (`service-automation`, `storedMetadataWriteRefusal`):
"create_record: refusing to create a record in 'sys_metadata': it holds
stored metadata, and a flow may not write it directly, so the write was
not run." Then the prescription.
- Save time (this PR): "This `create_record` node's `objectName` is
'sys_metadata', so it would create a record in a table that holds stored
metadata, and a flow may not write it directly: every run that reaches
the node refuses it before anything is written, and re-running changes
nothing." Then the prescription.
- One residual difference, in the prescription itself: the run-time node
spells "Elevation (`runAs: 'system'`)", while the shared constant (the
hook refusal's, now exported) spells "Elevation (`runAs`, a system
context)". Both say elevation does not change the outcome. Importing the
exported sentence in `service-automation` makes them identical; that is
a named follow-up, not done here.

## Census, before any edit

At `417443eb27`: **0** write nodes aimed at either family table outside
tests, across `packages/**`, `examples/**`, `skills/**`,
`content/docs/**` and `docs/**` (229 write-node declarations). The only
hits are the run-time half's own pins,
`write-nodes-stored-metadata-family-refusal.integration.test.ts` (a
static target at lines 270 and 271, and a parameterized one through
`configFor`). Those pins are a test of the run-time refusal, not a
writer. This PR re-expresses them (next section). The positive control
fired: the same windowed search finds those pins and this PR's new
tests.

## The run-time pins, re-expressed (claim revision `5977090032`, open
question 1 answered A)

The run-time half's pins
(`packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts`,
from PR objectstack-ai#21649) registered static family-target flows through
`registerFlow` in order to run them. `registerFlow` parses first, so the
save-time refusal turned 12 of their 17 cases red. The PM revised the
claim to add this one file, test-only (claim revision `5977090032`;
cross-lane declaration `5977094605` on objectstack-ai#21118). No other
`service-automation` file changes, and the engine, `crud-nodes.ts` and
the runtime are untouched.

**The edit.** One new harness step, `registerForRun(def)`, replaces the
two direct `registerFlow` calls (`runWatched` and `codeAsAFlowReadsIt`).
- A definition with no static family target registers exactly as before.
That covers the variable-target cases and the ordinary-object controls.
- A definition carrying one is first judged at save. `registerFlow` must
throw, with exactly one `custom` issue per family write node at its
path: `nodes.1.config.objectName`, or
`nodes.1.config.try.nodes.0.config.objectName` for the `try_catch`
region flow. Each issue's message must name the metadata protocol.
`getFlow(name)` must answer `null` (nothing was registered), and the
target table's snapshot must be unchanged.
- It then reaches the run-time guard with a definition the parse never
judged. The same definition is registered aimed at a stand-in object
(`pin_stand_in_target`, which does not exist). The family table is then
put back on the parsed definition `registerFlow` returned.
`getFlow(name)` must answer the family table at every original path
before the run starts.
- Every existing run-time assertion is unchanged, byte for byte: the run
fails, nothing downstream runs, no engine write reaches the family, the
table is unchanged, the flow reads `PERMISSION_DENIED`, a fault edge
does not route, and all of it under both identities and both
compositions. No assertion was removed or loosened.

**The engine behaviour the route relies on, and why it is stable.** Two
facts, neither touched by this PR, which changes no `service-automation`
source file:
- `AutomationEngine.registerFlow` stores the parsed definition it
returns, by reference: `this.flows.set(name, parsed)`, then `return
parsed`.
- `execute` runs `this.flows.get(name)` as stored and never re-parses
it. The engine documents `this.flows` as holding only `FlowSchema.parse`
output.

Both are read back rather than assumed. The `getFlow(name)` assertion
above must see the family table at every retargeted path. If the engine
ever copied, froze or re-parsed the stored definition, the retarget
would fail loudly: a copy or a re-parse leaves the stand-in in place, so
the read-back goes red and the run fails with not-found instead of the
family refusal; a frozen definition throws on the assignment itself. The
route cannot pass silently.

**Evidence** (at `a9d2d5d453`):
- The pins file: `Tests 17 passed (17)`.
- The full `service-automation` suite: `Test Files 170 passed (170)`,
`Tests 2098 passed (2098)`, 0 failed. `pnpm --filter
@objectstack/service-automation typecheck` exits 0, and
`check:test-typecheck` is OK. eslint on the file gives 0 errors and 0
warnings.
- **Run-time guard ablation** (`crud-nodes.ts`, predicted 12 red / 5
green). `scripts/ablation-replace.mjs` pointed
`storedMetadataWriteRefusal`'s family check at a name nothing matches.
The anchor went 1 to 0 and the blob `b9bb559a0c` to `ffe005789b`. No
build was needed: the pins reach it through relative `src` imports.
Observed `Tests 12 failed | 5 passed (17)`. Every first failure is a
run-time assertion ("the run must fail: expected true to be false"), and
0 are save-time assertions. So the save step passed, and the run then
wrote, which the pins catch. Restore: blob after restore `b9bb559a0c`
equals HEAD's, and `git diff HEAD` is empty; the script's own trap
re-confirmed it with 0 diff lines. The first attempt used a one-line
anchor that hits twice in the file (once in the `get_record` read
refusal). The tool refused it (`ANCHOR AMBIGUOUS`, exit 3) and wrote
nothing. The rerun used a longer anchor unique to the write refusal.
- **Save-time arm ablation** (spec, predicted 12 red / 5 green). The
arm's push became a `globalThis` marker assignment. The anchor went 1 to
0 and the blob `82a173479e` to `ee1614818e`. `@objectstack/spec` was
rebuilt, and `ablation-dist-preflight.mjs` proved the marker reached
`packages/spec/dist`, because `service-automation` resolves
`@objectstack/spec` through `dist`. Observed `Tests 12 failed | 5 passed
(17)`. Every failure is the new save-time assertion ("registerFlow must
refuse a static family target at save: expected undefined to be
defined"). Restore: blob `82a173479e` equals HEAD's and `git diff HEAD`
is empty. The rebuilt `dist` was proven free of the marker (`--absent`,
exit 0), and the rerun gave `17 passed`.

## Doors, tested and probed (all at `f7d1216a8f` unless noted)

- `FlowSchema.parse`: refused at `nodes.1.config.objectName` for each
write node and each family table, and at
`nodes.1.config.try.nodes.0.config.objectName` inside a region. The
message is the judge's own and ends on the leaf's prescription.
- `defineStack`: `STACK_SCHEMA_INVALID` / 422 at
`flows.1.nodes.1.config.objectName`. `ObjectStackDefinitionSchema` (the
stack parse `objectstack validate` runs) refuses at
`flows.0.nodes.1.config.objectName`. The registered `flow` type schema
(the metadata save door's) and an artifact's parse refuse too.
- `objectstack validate`, the real CLI on a temporary fixture (deleted
afterwards): with a `create_record` node on `sys_metadata` it gave exit
1, `"code": "STACK_SCHEMA_INVALID"`, and an error naming
`flows.0.nodes.1.config.objectName` and the prescription. The same stack
aimed at an ordinary object gave exit 0, `"valid": true`.
- `registerFlow`: refuses, because it parses first, and a committed pin
now says so. The re-expressed run-time pins assert the throw, its path
and the unchanged table for every static case (above). The throw comes
from `FlowSchema.parse` inside `canonicalizeStoredFlow`
(`engine.ts:4346`), which `registerFlow` (`engine.ts:4368`) calls.
- Lit controls: each write node on an ordinary object passes. A dynamic
`objectName` (`{record.target}`, `{target}`, and the envelope `{
dialect: 'cel', source: ... }`) passes at save, and the judge returns
nothing for it. `get_record` on either family table passes. The refused
set equals the predicate's by exact name (`SYS_METADATA`,
`sys_metadata_draft`, ` sys_metadata` with a leading space, `sys_meta`,
`metadata` all pass).

## Ablation of the spec pins (one-shot, not kept)

At `f7d1216a8f`, with the implementation committed,
`scripts/ablation-replace.mjs` replaced the arm's push line with a no-op
plus a marker. On disk the anchor went 1 to 0, the marker 0 to 1, and
the blob `82a173479e` to `a832964b45`. No dist rebuild was needed: the
tests reach the judge through relative `src` imports. Predicted 17 red /
29 green over the two pin files; observed `Tests 17 failed | 29 passed
(46)`. Restore: the tool reported blob after restore equal to the HEAD
blob (`82a173479e`) and `git diff HEAD` empty. The script's own
EXIT/INT/TERM trap, using `git checkout HEAD --` on the absolute path
plus a hash compare, re-confirmed it with 0 diff lines. The rerun gave
46 passed. An earlier run at `8f5adb6ad6` (before the stack-parse door
test existed) read 16 / 29, as predicted.

## Verification

Spec-side readings at `f7d1216a8f`. `packages/spec` has not changed
since; round 2 touched only the `service-automation` test file.
- `@objectstack/spec`, full local project: `Test Files 611 passed
(611)`, `Tests 18141 passed | 1 todo`.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
`check:test-typecheck` is OK, and `tsc -p tsconfig.test.json
--listFiles` lists both edited test files.
- `check:generated`: all 15 artifacts up to date, against a dist the run
built.
- `@objectstack/lint` (the judge's other caller,
`validateStackExpressions`): `Test Files 119 passed`, `Tests 5627
passed`.
- Lint, a proven narrowing (`pnpm lint` itself belongs to CI). eslint's
config lints 9 of the 12 round-1 paths: 0 errors and 0 warnings from
`--format json --no-inline-config`. It ignores the 3 that are `.md` /
`.json`. The config sets no `parserOptions.project` and enables no typed
rules, so this diff cannot move an untouched file's verdict.

Readings at `a9d2d5d453` (the head):
- `@objectstack/service-automation`: `Test Files 170 passed (170)`,
`Tests 2098 passed (2098)`. Typecheck exits 0.
- eslint on the round-2 file: 0 errors and 0 warnings. The control-byte
scan over the 13 changed paths found none.
- `dispatch-gates --commands` (no paths), derived fresh at this head: 94
families, the round-1 92 plus `check-tenant-audit-census` and its
self-test. All 94 were run fresh on this head, each exited 0, and
`--ran` with exit codes recorded reads 94 derived, 94 run, 0
NOT-MEASURED, 0 UNRUN.

`origin/main` was merged twice through `scripts/pm/os-regen-merge.sh`
(no rebase): once for objectstack-ai#21668 / objectstack-ai#21673, which landed `registry.ts`
changes, and once for `7d0781482d`. Each merge regenerated and
re-checked the artefacts afterwards. The sibling entries are all present
(each id counted the same on `origin/main` and here), and this branch's
`registry.ts` delta against `main` is +61 / -0.

## Acceptance notes

- The comment above the `flowNodeConfigRefusals` walk in
`packages/spec/src/automation/flow.zod.ts` still says "Two arms" and
lists two. With this PR there are three. The file is outside the claim's
surface, so it is noted, not edited. The judge's own docblock in
`flow-node-config-refusals.ts` states all three arms.
- `packages/lint/src/validate-expressions.ts` describes its call into
the judge as "a key its contract requires, left out, and a `decision`
branch list it cannot read". That list is now incomplete, not false: the
call also emits the new refusal, as `error`.
- Follow-up, not done here: `service-automation`'s
`storedMetadataWriteRefusal` and the runtime body boundary's private
`PRESCRIPTION` can import `STORED_METADATA_BODY_PRESCRIPTION` from
`@objectstack/spec/kernel`, which makes the sentence one.

Body refreshed 2026-10-04T06:42Z (round 2: the run-time pins
re-expressed). The docs-drift advisory on this PR was read: it is
advisory, and a spot-check of the hand-written pages naming
`sys_metadata` with flows found none that this change falsifies.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ecision in words instead of a tracker number (stage 9) (objectstack-ai#21698)

Part of objectstack-ai#20749
Clause-②: no

Stage 9 of the `domain:spec` lane's share of the runtime-string
burn-down (ruling `5902360492`, form D): the author-visible tracker ids
in **step 18's** rationale in
`packages/spec/src/migrations/registry.ts`, per the pointer `5968465093`
and stage 8's landing `5976757373`. Every cited decision is now stated
in words, or the citation is dropped where its sentence already said
what was decided. Text only. The test strings (class (e)) are a later
stage, so this PR closes no card.

## What changed

- **`STEP18_RATIONALE`: 49 of its 79 fragments carried 88 tracker ids.**
72 are objectstack issue numbers, 12 point into objectui (nine issues
spelled `objectui#N`, one issue spelled `ui#N` twice, one PR), and 4 are
director decision-batch numbers that the gate's id pattern also counts.
Exactly those 49 fragments changed, and each now reads id-free. Fragment
ids, orders and the other 30 fragments are byte-identical.
- **Two two-digit decision-batch numbers** (`decision batch objectstack-ai#91`, `objectstack-ai#77`)
sat inside two of the same parentheticals. The gate's pattern (three to
five digits) does not count them. They went with the sentence they
belonged to, and the ruling they named is stated instead.
- **No reader pinned the rewritten words.** Every string and regex
literal in the repository's code was matched against each fragment's old
and new text. No assertion that matched the old text fails on the new
one. See "Readers" below.
- **Nothing to regenerate.** No generated artefact prints step 18 yet:
`docs/protocol-upgrade-guide.md` prints majors up to `PROTOCOL_MAJOR`
(17), and `spec-changes.json` carries no step rationale.
`check:generated` reads all 15 artefacts up to date at this head. `os
migrate meta` prints the text at runtime today, because its chain runs
to the highest registered major.
- One `@objectstack/spec` **patch** changeset, `Clause-②: no`. The
rationale ships in `dist/migrations`.
- No step-17 text, no code comment, no test title, and no schema, order,
id, conversion or behaviour change.

## Census at the base (A1)

Base `7d0781482d`, the worktree before any edit, which is the claim's
stamp. I reused stage 8's instruments byte-identically (`census.cjs`,
md5 `6e42a45a926d375013c32d62f16a296e`; `skeleton.cjs`, md5
`a43a72c63f55c883da7a54b6f600ca08`) and held the census to the same
reference: `check-doc-authoring.mjs --census`, run from a scratch copy
whose `PACKAGES_PROSE_ROOT` / `PACKAGES_PROSE_EXCLUDED` point at
`packages/spec/src` and at nothing. The copy is byte-identical to the
one stages 7 and 8 used (md5 `8edc9a4064f55d2766c1eb0d3e8d889f`; the
live gate differs from it only on those two lines). Result: **88 = 88
ids, 0 differences per (line, id)**.

| region of `migrations/registry.ts` | messages | ids | gate literals |
|:--|--:|--:|--:|
| step 17 (`const step17`) | 0 | 0 | 0 |
| step 18 (`STEP18_RATIONALE`, lines 5025–6408) | **49** | **88** | 81 |
| step 18 entries (`surface` / `replacement` / `reason` /
`acceptanceCriteria`, generated regions) | 0 | 0 | 0 |
| rest of the file | 0 | 0 | 0 |

- **Reconciled with stage 8's 46 / 85.** I re-ran the instrument on the
registry at stage 8's base `eea82af677`: 46 / 85, reproduced. Between
that base and this one, PR objectstack-ai#21673 (stage 5 of objectstack-ai#21464, landed as
`72f3c74d60`) added three step-18 fragments, orders 71–73
(`ui-object-metric-compare-to-typed`,
`ui-object-metric-drill-down-typed`, `ui-object-grid-columns-typed`),
each citing that card once. 46 + 3 = 49 and 85 + 3 = 88. No other
fragment differs between the two bases. Ancestry with a control leg on
this shallow checkout: `72f3c74d60` is an ancestor of HEAD (exit 0) and
not of `eea82af677` (exit 1), while the older stage-7 landing
`36e4647520` is an ancestor of `eea82af677` (exit 0).
- 1859 files scanned, 1355 parsed, 0 parse diagnostics. Test strings at
the base: 1803 messages / 1919 ids in 425 files, stage 8's after-count.
- **Lit controls:** at the base, `:5066` (`objectstack-ai#8681`) and `:5126` (`objectstack-ai#118`)
count. After the rewrite, an id planted into a scratch copy of the new
file (one literal of the `targetVariable` fragment) reads 1 message / 1
id, folded over the whole fragment (lines 5515–5522).
- **Dark controls:** in the `STEP18_RATIONALE` region, 96 raw `#NNN`
hits, 8 of them on comment lines (the step-18 docblock,
`:6421`–`:6440`), which read 0: 96 − 8 = 88. Past `:6444`, 957 raw hits
sit on comment lines in the generated entry regions, and all read 0. The
header docblock (`:19`, `:32`, `:40`, `:62`) and step 17's 18
comment-line hits read 0.

## After

At `9b8c7f38d7`: step 18 reads **0 / 0**, step 17 still reads 0 / 0, and
`packages/spec/src` outside test strings reads 0 messages / 0 ids. The
gate reference agrees (0 sites). Test strings are unchanged at 1803 /
1919.

## Text-only proof

Stage 8's `skeleton.cjs`, base copy against the head file: **SAME —
22335 skeleton tokens, 3508 string groups, 49 changed (each id-bearing
before, id-free after), diagnostics 0/0.** A `+` chain's string run is
one group, so a fragment's whole text is one group; the 49 changed
groups are the 49 fragments, and every other string in the file
(fragment ids, entry ids, surfaces, conversions) is byte-identical.
Controls, each mutation counted on disk on a scratch copy of the head
file (anchor hit 1, mutation landed):

| control | expected | got |
|:--|:--|:--|
| rename the `step18` binding | DIFF, exit 1 | exit 1 |
| a fragment's `order` 43 → 44 | DIFF, exit 1 | exit 1 |
| a fragment's `id` string renamed | VIOLATION, exit 3 | exit 3 |
| a step-18 entry's `surface` edited | VIOLATION, exit 3 | exit 3 |
| a literal re-split inside a `+` chain | SAME, exit 0 | exit 0, 49
changed |
| an id-free fragment's text edited | VIOLATION, exit 3 | exit 3 |
| one fragment reverted to its base text | SAME, 48 changed | exit 0, 48
changed |
| an id planted in a rewritten fragment | VIOLATION, exit 3 | exit 3 |

Lines grew where words replaced a number: the longest new line is 111
characters, inside the region's existing maximum of 117. There is no
`max-len` rule; ESLint reads 0 / 0 on the file.

## Per-record table

Every cited record was read through REST with all its comments (59
objectstack issues and PRs, 11 objectui issues and PRs). The four
three-digit decision-batch numbers are not cards of this repository:
objectstack issues 118, 121, 132 and 210 are unrelated broken-link
reports and a docs PR from 2026-01, which is the control that they are
batch numbers. Each batch's decision was read from where it is recorded
(a ruling comment, or the spec CHANGELOG). Numbers below are written
bare.

| fragment (order) | record | decision as read (where) | in the text now
|
|:--|:--|:--|:--|
| `metadata-plugin-additional-types-retired` (2) | 8586 | remove
`additionalTypes` (ruling 5293094846) | dropped |
| `field-scale-precision-integer-refused` (3) | 8321 | refuse malformed
`scale` / `precision` at authoring (body, ACCEPT 5296942541) | dropped |
| same | 7501 | enforce `scale` by rejecting, never by rounding (ruling
5250623270) | "the write-time `scale` check, which refuses an over-scale
value rather than rounding it" |
| `admin-export-wildcard-removed` (4) | 8681 | remove `allowExport` from
the `'*'` entry of both admin sets (ruling 5299825940) | dropped |
| same | 5491 | remove `member_default`'s `'*'` object grant on create /
read / edit (ruling 5219845380) | "the earlier removal of
`member_default`'s CRUD wildcard" |
| `record-chatter-position-vocabulary-converged` (5) | 8762 | the
renderer's vocabulary, a conversion, the defaults dropped (ruling
5299771841) | dropped; the ruling's date stays |
| `element-input-target-variable-retired` (6) | 9198 | retire the hint,
the retirement route (ACCEPT 5311252358) | dropped |
| `element-filter-retired` (7) | 9220 | retire `element:filter` at
element grain (verdict 5312176877) | dropped |
| same | 9198 | its sweep recorded the element-grain lead and left it |
"the wider finding that the `targetVariable` retirement recorded" |
| `element-form-retired` (8) | 9249 | retire `element:form` at element
grain (cloud reading 5350190376) | dropped |
| same | 9220 | the `element:filter` retirement's own verdict sweep
found it | "the `element:filter` shape … recorded by that retirement's
own verdict sweep" |
| same | 7751 | direction A: the `object-*` blocks' props enter
`ComponentPropsMap`, so the component-props gate judges them (ruling
5261743573) | "its props declared for the component-props gate" |
| `field-inline-and-related-list-columns-closed` (9) | 9227 | a strict
element schema for `inlineColumns`, `relatedListColumns` in the same
pass (ruling 5315735776) | dropped |
| same | objectui 3951 | the published spelling `name` wins, the reader
is fixed, no tolerant dual read (ruling 5236150020) | "objectui aligned
the widget to `name` and retired the `field` spelling with no tolerant
alias" |
| `cube-metric-filters-retired` (10) | 10414 | the remove leg (triage
5363699539) | dropped |
| same | 10298 | the dataset path answered unfiltered aggregates under
the measure's name (body) | "the same defect the dataset path had" |
| same | 10411 (PR) | the strategy compiles a measure's `field` and
`filter` on both doors (ACCEPT 5360094389 on 10298) | "repaired … when
the analytics strategy began compiling each dataset measure's `filter`"
|
| `record-highlights-field-icon-retired` (12) | 10054 | option A, retire
(ruling 5364978909) | dropped; the ruling's date stays |
| same | 8691 | the rail's row declares what the renderer reads; `icon`
is refused, since no render path reads it (ACCEPT 5296436860) | "the
shape that got the reference-rail `icon` refused" |
| same | 5176 | A: declare `readonly`, because the chip's gate reads it
(ruling 5196402876) | "declared because the chip's read-only gate reads
it" |
| `page-component-responsive-retired` (15) | 11027 | B: retire, and
repair the four redirect texts (ruling 5380752244) | dropped |
| same | 4876 | A: retire `widgets[].responsive` (ruling 5169512655) |
dropped; "tombstone" already says it |
| `object-grid-default-sort-retired` (17) | 11805 | retire, the
changeset not major (ruling 5404972152) | dropped |
| same | objectui 5861, objectui 4869 | 2026-08-22 「接受所有」: lower the
legacy pair through the shared sort sink now, and retire
`table.defaultSort` on its own card (ruling 5377367456 on 4869) | "the
producer half of objectui's `table.defaultSort` retirement, which the
maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered" |
| `permission-restore-purge-bits-retired` (18) | 12497 | the
implementing card of the ruling below (body) | dropped |
| same | 1883 | B: retire the two bits; the card stays open as the M2
anchor, and the keys return with the feature (ruling 5421209848); gating
operations that do not exist is false compliance (ruling 5163046733) |
"which chose retiring the two bits over gating operations that do not
exist"; "the M2 lifecycle initiative …, which stays open as their
anchor" |
| same | 8106 (PR) | pins the engine's seven-verb dispatch vocabulary
(body) | "which a test pins" |
| same | 3004 | `owner_id` is server-managed for non-privileged writers
(body); `allowTransfer` enforced through that write guard (1883 ruling
5163046733) | "the server guards who may rewrite a record's owner" |
| `field-reference-to-spelling-retired` (21) | 13700, objectui 6837 | C:
the server normalizes the protocol and the renderer only executes it;
half 1 on the server, half 2 deletes objectui's arms (ruling 5475017957;
PASS 5480047935) | "the server half of the maintainer's 2026-08-31
ruling …"; "which the ruling's objectui half deletes" |
| same | 4923 | equal values: the shadowed alias is deleted with a
notice; different values: both kept, the strict door names both (ruling
5173149122) | "the house precedence for a shadowed alias" |
| `compliance-deadline-keys-retired` (23) | 14477 | A: retire per
family; e-signature too unless roadmapped (ruling 5518646938) | dropped;
"the deadline-key ruling" |
| same | 15513 | A: the three families retire whole, not roadmapped
(ruling 5548577921) | dropped |
| `duration-keys-unit-in-key` (24) | 14478 | B: a gate plus a conversion
of every offender, no baseline (ruling 5518649320) | dropped; the
ruling's date stays |
| same | 14519 | folded into that ruling (5546967881) | dropped |
| `metadata-manager-config-inert-cache-keys-retired` (25) | 14478 | the
rename gave `ttl` its `ttlSeconds` spelling | "the duration rename's
respelling of `ttl`" |
| same | 15624 | the outer three keys are read by nothing (triage
5548953183) | dropped |
| `cron-positions-deleted` (26) | 16320, 15954 | retire the seven cron
positions family by family (option A), not mark them experimental
(ruling 5559778263) | "the 2026-09-06 ruling retired each family rather
than marking it experimental" |
| same | 3786 | derive a list from its single source, never a hand copy
(sweep 5131678201) | dropped; the sentence says what the lint does |
| `list-view-page-mount-retired` (27) | 17063 | 「撤」, 2026-09-09 (body) |
dropped |
| `object-kanban-quick-add-retired` (28) | 17260, objectui 8285, batch
91 | B: the board grows no inline record-creation path, and the key
retires (ruling 5583979207) | "the director-seat ruling of 2026-09-08
that the board grows no inline record-creation path and retires the key"
|
| `list-view-sort-string-clause-retired` (29) | 17053, objectui 8221,
batch 77 | B: the legacy string clause is retired, one spelling, the
array (ruling 5567944420) | "ruled 2026-09-07: the legacy string clause
is retired, one spelling, the array" |
| same | objectui 8758 (PR) | `convertSortToQueryParams` refuses a
runtime string; merged 2026-09-09, before the spec half | "whose
consumer half shipped in objectui first" |
| `chart-config-aria-retired` (31) | batch 118 item 2 | recommendation
C: remove rather than enforce, the protocol judged wrong for this one
key (spec CHANGELOG entry `2bf6ef1`) | "maintainer decision of
2026-09-12 — judge the protocol wrong for this one key" |
| `dashboard-widget-chart-config-structure-refused` (32) | batch 121
item 1 | C+D: the dataset owns structure, `chartConfig` appearance, and
the structural keys are refused by name (ruling 5644017639 on card
17385) | dropped; the ruling's date stays, and the sentence states the
split |
| `translation-per-app-settings-platform-only` (33) | 15178, batch 132
item 2 | ②: the bundle type splits, and the per-app bundle refuses
`settings` (ruling 5653315643) | "maintainer ruling 2026-09-13: settings
copy belongs to the platform" |
| same | 19620, batch 210 item 2 | B: `settings` leaves the item door
too, one app metadata type, one shape (ruling 5770445203) | "maintainer
ruling 2026-09-22: one app metadata type, two authoring doors, one
accepted shape" |
| `object-tenancy-organization-field-retired` (34) | 19054 | off the
authorable surface, kept as a platform fact (body) | dropped |
| `page-component-filter-record-to-rule-array` (36) | objectui 6206 | B:
one filter spelling platform-wide, the rule array (ruling 5406409590) |
"ruled 2026-08-25: one filter spelling platform-wide, the rule array" |
| same | 17321 | B: a partial conversion of the lossless subset;
combinators stay as stored (ruling 5644018752) | "ruled 2026-09-12"; the
sentence states the rest |
| `view-item-owner-hidden-retired` (37) | 20085 | retire both keys
(triage 5826969296) | dropped |
| `ui-report-joined-chart-retired` (38) | 20161 | retire (triage
5852548444) | dropped |
| `view-overlay-owner-hidden-retired` (39) | 20230, 20085 | follow the
view item's disposition for the same pair (triage 5856621469) | "the
view item's disposition for the same key pair, followed here as triage
directed" |
| `ui-form-layout-inline-grid-retired` (40) | 20221 | retire `inline` /
`grid` (triage 5855767378) | dropped |
| same | 18900 | A′: a capability mainstream platforms have gets its
consumer once; one they lack retires (ruling 5727134555); triage applied
it: multi-column already exists, as `columns` | "the maintainer's family
criterion (a capability mainstream platforms have is served once, here
by `columns`)" |
| `currency-config-precision-retired` (41) | 19992 | retire (triage
5817146460) | dropped |
| `permission-rls-tags-retired` (42) | 20321 | RETIRE by the criterion
(triage 5860425529) | dropped; the sentence states the criterion |
| `flow-decision-edge-branching-first-match` (45) | 15429 | 「跟主流对齐」:
first match, inclusive only when declared (ruling 5793803317,
2026-09-23) | dropped; the ruling's date added |
| `ui-object-master-detail-form-details-closed` (56) | 20928 | a strict
detail entry (ACCEPT 5936904192) | dropped |
| `ui-record-line-items-props-closed` (57) | 21142 | the row and the
producer fix (ACCEPT 5941249538) | dropped |
| `ui-object-grid-export-options-closed` (59) | 21229 | the object form
only, refused, not lifted (triage 5939380297) | dropped |
| `translation-widget-sub-caption-retired` (60) | 21257, objectui 11389
| C: retire both ends (ruling 5942430353, 2026-10-01) | "maintainer
ruling 2026-10-01" |
| same | 5428 item 4 | the sub-caption got its own key beside
`widget.description` (ruling 5200254075, 2026-08-06), which C reverses |
"which reverses the 2026-08-06 ruling that gave it a translation key of
its own" |
| `object-grid-resizable-columns-retired` (62) | 21445 | retire the
alias (triage 5958164933) | dropped |
| same | objectui 6152 | `resizable` is canonical, and the alias retires
with no window (seat answer 5957903936) | "objectui's ruling that
`resizable` is canonical" |
| `ui-object-grid-row-members-typed` (63) | 21445 | type the members
(triage 5958164933) | dropped |
| `ui-object-map-gantt-tree-navigation-typed` (64), list members (66),
form members (67), metric aggregate / trend (69), compare-to (71),
drill-down (72), grid columns (73) | 21464 | the staged close-out (seat
answer 5963787404) | dropped ×7; each sentence names its stage |
| `ui-ai-chat-window-retired` (65) | 21504 | retire (triage 5963897014)
| dropped |
| `element-text-variant-heading-subheading-retired` (68) | 21015 |
release 2 of objectui's ruled two-release split (body) | dropped; the
sentence names the split |
| `object-master-detail-form-detail-sort-field-retired` (70) | 21589 |
retire (triage 5969870827) | dropped |
| same | objectui 11070 (round 9) | the authored override retires; the
sort field is derived from the child object (seat answer 5928627070,
landed 5930904717) | "the spec half of objectui's own retirement of the
override" |

## Readers

- **Searched by substring, not by guess.** A TypeScript-AST pass over
all 9878 tracked code and text files collected every string literal (8+
characters) and regex literal, and tested each against each fragment's
old and new text, and against the joined rationale. A reader is owed a
move only if it matched the old text and not the new. In the files that
read the registry or the rationale, the only literals that lose a match
are generic words and patterns that match some fragment by coincidence
(`'decision'`, `'objectui'`, `/100/`), plus the tracker-id detectors,
which are absence checks that now pass. **No pin needed moving.**
- **The step-18 readers that exist, and why they hold:**
- `ui/component.test.ts` pins the phrase "retires `object-kanban`'s
`quickAdd`" and pins that the rationale never says `kanban-ui`. That
second pin shaped the rewrite: objectui's ruling kept the control on
`kanban-ui`, a node type objectui has since retired, so the new text
states the ruling without naming it.
- `system/compliance-families-retirement.test.ts` pins `/retires those
three compliance-shaped families WHOLE/`, kept verbatim.
- `data/cube-metric-expression-types-retirement.test.ts` reads a
fragment this stage does not touch.
- `scripts/step18-rationale-merge.test.ts` pins the fragments' sort and
that the rationale equals their joined text. Neither moves.
- **No reverse verification was owed:** no pin moved, so there is no
moved assertion to turn red. The text-only proof's controls and the
census's lit control stand in its place.
- **Parallel copies, not readers:** the spec CHANGELOG, the 17.1 release
notes and three liveness-ledger notes carry their own copies of some old
phrases. They are release-owned or ledger notes, and nothing compares
them with the rationale.

## Tests and gates (at `9b8c7f38d7`)

All builds and tests ran through `scripts/pm/os-verify-lock.sh`, with
each exit code written to a file before reading.

- `pnpm --filter @objectstack/spec build`: exit 0, 38/38 dts.
- `pnpm --filter @objectstack/spec check:generated`: exit 0, all 15
artefacts up to date.
- The step-18 readers, local project: `migrations.test.ts`,
`component.test.ts` and the cube-metric test, 561 / 561. Repo project:
`step18-rationale-merge.test.ts` and the compliance test, 19 / 19. The
other 19 repo-project tests that import the registry: 314 / 314.
`conversions-major18-merge.test.ts`: 12 / 12.
- Full spec suite, local project: 610 files, 18113 passed, 1 todo.
- `pnpm --filter @objectstack/spec run typecheck`: exit 0.
- A turbo build of 71 / 71 packages, then the derived union: **84 / 84
exit 0**, reconciled by `dispatch-gates --ran` (84 run, 0 NOT-MEASURED,
0 UNRUN). Three gates first answered PREREQUISITE NOT MET (exit 3,
nothing measured) before the 71-package build, and passed after it.
- ESLint on `registry.ts`, a proven narrowing: 1 file, 0 errors, 0
warnings. The population comes from ESLint's own config
(`calculateConfigForFile` returns a config, `isPathIgnored` is false).
Invariance: `parserOptions.project` and `projectService` are null, so no
type-aware rule exists and an untouched file's verdict cannot move.
- `dist`: the new phrases are in `dist/migrations/index.js` and
`index.mjs`; the old runtime phrases probed are in no `dist/migrations`
file. The ids still present in `dist` are code comments, which this
stage does not touch.
- NOT MEASURED locally: the spec repo project as a whole (stopped at the
foreground limit with no output; its registry readers ran above), and
`scripts/build-schemas-check-mode.test.ts`, which reads semantic
`surface` strings and never the rationale. Both are CI's, as is the
repo-wide `pnpm lint`.

## Overlap

objectstack-ai#21654's PR objectstack-ai#21687 (draft, this seat) adds one `STEP18_RATIONALE`
fragment (order 74, no tracker id in its text) and one D3 entry. A local
`git merge-tree` of this head with its head `a9d2d5d453` is clean, and
`registry.ts` has no merge driver, so that result is the plain text
merge GitHub runs. Whichever lands later merges `origin/main` through
`scripts/pm/os-regen-merge.sh`. `origin/main` was re-fetched just before
opening this PR (`38bef8cf95`): the four commits since the base touch
neither this file, the conversions registry nor the guide, and a local
`git merge-tree` with it is clean.

## Acceptance notes

- **Out of this stage's reach, noted:** `registry.ts`'s code comments
still carry many ids (the step-18 docblock and the generated entries'
comments), and so does `dist`, which keeps comments. They are the
comment lane's share.
- **Kept on purpose:** `decision-inbox batch 4` and `batch 5` name a
batch without a `#` number and are not ids. Present-tense clauses whose
follow-through has since landed (for example "held up today only by
objectui's fallback arms") were left as written: form D asks for the
decision, and the decision is stated.
- **Ledger notes:** `packages/spec/liveness/dashboard.json`, `page.json`
and `view.json` cite some of the same decision batches in their notes.
They are not runtime strings and sit outside this census; nothing here
touched them.

## Clause-② and changeset

`Clause-②: no`. Nothing authorable, exported or typed is added, removed
or renamed; only the value of `MIGRATIONS_BY_MAJOR[18].rationale`
changes. It ships in `dist` and `os migrate meta` prints it, so one
`@objectstack/spec` patch changeset is owed, and `skip-changeset` does
not apply.

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

---------

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants