Skip to content

fix(spec): the field → name respelling reaches a form view's subform columns as its own ADR-0087 step, and two step-18 entries read true (#20901) - #20953

Merged
os-justin merged 6 commits into
mainfrom
claude/issue-20901-grid-column-followup
Sep 30, 2026
Merged

os-justin merged 6 commits into
mainfrom
claude/issue-20901-grid-column-followup

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20901

Clause-②: no

The follow-up the contract review of PR #20927 owes (review record 5919009567, items 5, 6 and 9, and the ① text findings), dispatched by landing record 5919493523. #20901 stays open for the seat to close by hand.

What changed

  1. Review item 5, the step-18 entry inline-grid-column-currency-scale-refused. Its surface said a column that declares no type "is untouched", and its acceptance said such columns "keep their scale". Since bee75cebe6 that is false for an identity-only column over a currency child field, which defineStack refuses. The surface, the reach sentence of the reason and the acceptance now say so, and each names the sibling entry inline-grid-column-identity-only-currency-scale-refused. The registry was regenerated with gen:migration-registry.
  2. Review item 6, ruled WIDEN: the field → name respelling is now an ADR-0087 chain step on the form-view carrier. New D2 conversion form-view-subform-columns-canonicalized (protocol 18, retiredFromLoadPath: true, retiredAfter: '17.5.0'). It walks every FORM payload mapViewPayloads reaches, which is form, each formViews entry, a form view item's config and a flattened form overlay (the stored-row seam wraps a view row as { views: [row] } in any of those spellings). It also walks the assembled viewItems channel. It uses the same respelling rule as field-column-lists-canonicalized, now one shared function (respellInlineGridColumns) called by both entries. An entry already spelled name is left alone, and so is one carrying both keys, exactly as the existing walk behaves. The D3 entry form-view-subform-columns-closed said "NOT mechanically converted". It now names this conversion, and its comment no longer says a stored row is "refused": a stored row is diagnosed at rehydration.
  3. Review item 9. A pin in inline-grid-column-carriers.test.ts probes InlineGridColumnSchema for every (type, key, value) that parses with the key alone and with the type alone but not with both. Today it finds exactly currency + scale (at 0 and 2), and it asserts that defineStack refuses each such rule on an identity-only column over a field of that type. A new type-conditional rule without a HYDRATED_INLINE_COLUMN_TYPE row goes red, and the failure message names the table. stack.zod.ts itself is unchanged apart from comments.
  4. The ① text findings, in the files touched above. stack.zod.ts: the objectui hydrateColumns / fieldTypeToColumnType claim is now a historical pin citation (.objectui-sha pin db11afd4967c, re-read at that sha: hydrateColumns leaves a typed column alone, and fieldTypeToColumnType's only currency arm is case 'currency'), and check:objectui-pin-citations lists it. The over-broad "like the view data-source check" is cut. The instruction "adds its row here" now names the pin that holds it.

Route change on item 6: its own entry, not a wider walk inside field-column-lists-canonicalized

The ruling's intent is carried out exactly: a lossless respelling on the new carrier, landed as a retiredFromLoadPath chain step in the same release. The vehicle differs from the ruling's wording, for one ledger fact the gates cannot see. retiredAfter is one value per entry: "the last published @objectstack/spec whose authoring surface still accepted the old shape" (ADR-0087 amendment of 2026-09-30, maintainer ruling A on #20390). field-column-lists-canonicalized is published with 17.0.0, and retired-after.census.test.ts pins that value, while the form-view carrier accepted field through 17.5.0. The artifact-ingestion door opens its window per entry by that value. Measured at this head, with applyArtifactForwardConversions, an artifact declaring engines.protocol: ^17.5.0, and runtime label 17.5.0:

  • verdict converted-retired-after;
  • the subform column { field: 'quantity' } became { name: 'quantity' } through the new entry (listed in replayedRetirements with retiredAfter 17.5.0);
  • the same { field: 'quantity' } on a relationship field's inlineColumns stayed as authored, because field-column-lists-canonicalized (17.0.0) stays closed at that floor. That is correct for that carrier, and it is what a folded-in subform walk would have done to the new carrier: the refusal instead of the rewrite.

A second, smaller reason: the per-release section of spec-changes.json reports conversion ids that are new in a release, so a widened, already-published id would be invisible there. If the seat still wants one id, the fold-in is mechanical: move the mapViewPayloads walk into field-column-lists-canonicalized and delete the sibling. The cost is the artifact-door gap above.

Verification (at 06f079864a, which carries origin/main 3fbf3ca617)

  • Pins packages/spec/src/inline-grid-column-carriers.test.ts: 21 passed (13 existing + 8 new).
  • Ablation 1 (scripts/ablation-replace.mjs, registry.ts: the new entry's respellInlineGridColumns(subform.columns, …) call replaced by subform.columns; anchor x1 → x0; blob 909148b74725 → 3b07d9f2d145), run over the pins plus conversions.test.ts: Tests 4 failed | 251 passed (255). The red ones are the fixture pair of form-view-subform-columns-canonicalized, the stored-row test in all three view spellings, the os migrate meta chain test, and the two-carriers-one-rule test. The controls (an entry spelled name, one carrying both keys, and the authoring funnel's refusal) stayed green. Restored: blob == HEAD, git diff HEAD empty.
  • Ablation 2 (stack.zod.ts: the currency row deleted from HYDRATED_INLINE_COLUMN_TYPE; blob f93870ec6a88 → d3aca2ec1d38): Tests 4 failed | 17 passed (21). The new table pin is red with "…add the row to HYDRATED_INLINE_COLUMN_TYPE in stack.zod.ts", and so are the three existing hydrated refusals. Restored: blob == HEAD.
  • Both ablations ran from committed state (ca5ad202a9). The subject resolves to src through relative imports, so no dist leg was owed.
  • Spec suite vitest run --project local: Test Files 584 passed (584) / Tests 17200 passed | 1 todo. Typecheck pnpm --filter @objectstack/spec typecheck: exit 0 (check:test-typecheck: OK; tsconfig.test.json --listFiles includes the pin file). Repo-project subset (major-18 merge, step-18 rationale merge, retired-key migrate sentence, two view retirement pins): 82 passed. The full test:repo project is left to CI.
  • check:generated: all 15 artifacts up to date after a fresh pnpm --filter @objectstack/spec build.
  • Derived gates node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 90 derived, 90 run, all exit 0. --ran reconciliation reports "0 NOT-MEASURED (a DERIVED zero — all 90 recorded an exit code)". Four first exited 3 (PREREQUISITE NOT MET: lint check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt) and were green on a re-run after turbo run build over ./packages/* and ./packages/*/*.
  • ESLint (a narrowed run, stated as such): eslint --no-inline-config --format json over the 7 changed .ts files reports files 7, errors 0, warnings 0. The population is eslint.config.mjs's **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}. That config never enables type-aware linting (its own comment: no parserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file.

One test touched outside the dispatched files

packages/spec/scripts/conversions-major18-merge.test.ts went red on this diff. It builds its synthetic retirement pair beside the entry at the middle of MAJOR_18_CONVERSIONS. At the base that neighbour was hookTimeoutToTimeoutMs, which predates the placement rule. With this entry added, the neighbour is formViewSubformColumnsCanonicalized, which is placed by the rule. The "defined after every other conversion" variant then reports the neighbour too, because its entry is now followed by the synthetic one. The expectation now derives that neighbour finding when, and only when, the neighbour is not exempt. At the base it reduces to the old single-element list. 12/12 pass.

Changeset

@objectstack/spec: patch. The act adds no exported symbol (check:api-surface green) and no accepted key or value on an authoring surface: authored sources are refused exactly as before. It rewrites stored and assembled data that the contract already names (name), so under the "WHICH LEVEL" rule it is a fix and stays patch. Clause-②: no, per the gate's definition (a new key on a published payload): the conversion adds no key, and the step-18 edits are prose. It is not a narrowing either, since the data-at-rest seams now accept strictly more.

Acceptance notes


Generated by Claude Code

… carrier; step-18 entry texts name the identity-only sibling

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…pe-conditional rules, and the form-view respelling's reach

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…n the neighbour is placed by the rule

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 10 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/stack.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/getting-started/common-patterns.mdx (via unit_price (literal, a string literal in fixture))
  • content/docs/protocol/objectui/layout-dsl.mdx (via unit_price (literal, a string literal in fixture))

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

  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object formViewSubformColumnsCanonicalized), retiredFromLoadPath (symbol, a field of const object formViewSubformColumnsCanonicalized))

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
  • 1 changed file(s) yielded no anchor (packages/spec/src/stack.zod.ts) — pages documenting those are invisible to this run
  • 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 — 137 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 95fed33a20dbefb8afdaa852724c3b78bccf93a0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 95fed33a20dbefb8afdaa852724c3b78bccf93a0

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 06f079864abecbfefdefac2642e8a2ecb1438fd5
Local-runs: none

① Derived judgments

Inputs read: card #20901 (body; comments 5916820410 triage, 5916997003 claim, 5918594163 and 5920703403 dev reports, 5918621378 and 5920724494 seat rulings, 5919493523 landing record), the at-tier record and seat adoption 5919009567 on PR #20927 (items 5, 6, 9 and its ① text findings), ADR-0087 on origin/main (the pre-GA chain policy, the 2026-09-13 level amendment, the 2026-09-30 per-entry retiredAfter amendment), PR #20953 body and file list (8 files, +427/−58), the net diff origin/main...06f079864a (merge-base 3fbf3ca617), and the head's check-runs. Tree facts were read with git show / git grep / git diff on origin/main (013f97df93) and the head; the two regenerated registry hunks were compared to their entry sources by text. Nothing was built, run or re-run; the dev's counts and ablations are not re-measured here — the check-runs stand for them.

Accept-set and public-surface changes the diff implies

  1. New D2 conversion form-view-subform-columns-canonicalized (toMajor: 18, retiredFromLoadPath: true, retiredAfter: '17.5.0', order: 51; list entry sorted between formViewOptionDefaultRemoved and hookTimeoutToTimeoutMs, definition placed directly above hookTimeoutToTimeoutMs's — the [finding] conversions/registry.ts: every major-18 retirement with a D2 conversion appends to the CONVERSIONS_BY_MAJOR[18] tail, so two in flight conflict in GitHub's merge (the sibling of #20535) #20574 placement rule; PLACED_BEFORE_THE_RULE stays at 46). RIGHT, and lossless: respellInlineGridColumns is the sibling's former inline loop lifted verbatim — same guard (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry returns the entry by reference), same renameKey (moves the value to name, deletes field, every other key kept, walk.ts:654), same notice path string (the caller now passes path.inlineColumns, so the sibling's emitted paths are unchanged), copy-on-write, idempotent (no field survives a rewrite). A name-spelled entry and a both-keys entry are returned untouched — the 'name' in entry guard runs before renameKey, so its equal-twin delete branch is never reached on either carrier; that is what the docblock and the changeset say.

    • Reach is exactly the stored shapes a field-spelled subform column can sit in. mapViewPayloads (walk.ts:565) walks stack.views[] in the three ViewMetadataSchema spellings: the ViewItem record (viewKind plus a dict config), the container (form and every formViews entry; list/listViews arrive labelled list and the kind === 'form' guard skips them), and the flattened overlay (viewKind: 'form', config absent). applyConversionsToStoredItem('view', row) wraps a row as { views: [row] } (stored.ts, SINGULAR_TO_PLURAL), so every stored view row spelling is reached. The viewItems walk (ASSEMBLED_VIEW_ITEMS_KEY) covers an assembled manifest's non-container branches — record config, flattened overlay, a list item skipped, a present-but-malformed config left for the parse — the same shape formLayoutInlineGridToVertical uses. FormViewSchema is the only *.zod.ts schema that declares subforms (one site, view.zod.ts:4360), and FormViewSchema sits only under ViewSchema.form / formViews, the ViewItem form arms and the overlay member — no page component and no object carries a form view; ObjectMasterDetailFormPropsSchema.details is a different key (finding(spec): ObjectMasterDetailFormPropsSchema.details is z.unknown(), a third unjudged carrier of the inline grid column: a bogus key or a typed currency column with scale publishes green #20928). Fixture: 5 notices = 2 in form, 1 in formViews.quick, 1 in the assembled record, 1 in the assembled overlay; the name-keyed and both-keys entries emit none. Chain: step 18's conversionIds is derived from CONVERSIONS_BY_MAJOR[18] (migrations/registry.ts), so os migrate meta --from 17 picks the id up with no registry edit, and applyMetaMigrations calls apply directly (apply.ts docblock) — pinned. Authoring funnel unchanged: normalizeStackInput never sets includeRetired, so defineStack and validate still refuse field with the alias prescription (field → name) — pinned as STACK_SCHEMA_INVALID 422.
    • Artifact door: applyArtifactForwardConversions replays an entry when the floor is below the runtime label OR the floor is at or below the entry's retiredAfter (module docblock; ADR-0087 amended 2026-09-30). A 17.5.0-floor artifact on a main still labelled 17.5.0 is rewritten by this entry rather than refused; the entry is a rename, not a default flip, so DEFAULT_FLIPS_NOT_REPLAYED_HERE does not apply.
  2. Its own id with retiredAfter: '17.5.0', not a widened walk inside field-column-lists-canonicalized. RIGHT, and what the ADR-0087 per-entry amendment requires. retiredAfter is one required field per retired entry (conversions/types.ts:285) and the door keys its window off it per entry. field-column-lists-canonicalized carries 17.0.0, and the census pins it: the first tarball listing it retired is 17.1.0 (retired-after.census.json, 17.1.0 block), so retired-after.census.test.ts ("every PUBLISHED entry carries the stable release before the first tarball that retired it") holds it at 17.0.0. The form-view carrier accepted field through 17.5.0 — the card measured 17.5.0's FormViewSchema accepting any column, and feat(spec)!: a form view's subform columns are the inline grid column contract, and an identity-only column is judged as the type it renders (#20901) #20927's changeset 20901-inline-grid-column-carriers.md is still in .changeset/ on origin/main, so its narrowing is unreleased. A widened 17.0.0 entry would leave the door shut for a 17.5.0-floor artifact (17.5.0 is not at or below 17.0.0) while the label still reads 17.5.0 — the exact refusal the amendment closed. For an UNPUBLISHED entry the census test wants the label (17.5.0), or, while the label is ahead of the census's last release (17.4.0), any value in [17.4.0, 17.5.0]; 17.5.0 is inside and is the true fact. This record reaches A from the ADR text and the door's docblock independently of ruling 5920724494.

  3. Regenerated migrations/registry.ts: additive and generator-produced. Compared by text at the head: both amended entry bodies, and the form-view entry's leading comment, are byte-identical to their entries/semantic/18.*.ts sources after the generator's indentation (the currency-scale entry has no leading comment on either side); the diff holds no other hunk and no entry is added or removed (text-only edits, +28/−19). spec-changes.json and docs/protocol-upgrade-guide.md project majors up to 17 only ("to": 17; zero major-18 ids in either at the head), so no regeneration of those was owed for a major-18 entry — confirmed statically, not only by the green generated-artifact gates.

  4. conversions-major18-merge.test.ts: the change DERIVES the expectation, it does not weaken it. The producer's finding string is X is defined above Y; define it directly above Z's definition, the entry that follows it (test lines 140-142); the expectation adds exactly that string for the middle pair's neighbour IDENTS[k-1] when the neighbour is not in PLACED_BEFORE_THE_RULE, and the A finding stays required. With 51 entries the pair lands at k = 25 (formViewSubformColumnsCanonicalizedSyntheticA in gap 25, hookTimeoutToTimeoutMsSyntheticB in gap 26), so the neighbour is the new rule-placed entry and the definitions-end control must now report two findings; at the base (50 entries, neighbour hookTimeoutToTimeoutMs, exempt) it reduces to the old single element. The control is still required to be red.

  5. Hydrated-type table pin (review item 9): the probe reads InlineGridColumnSchema's own type enum and every key other than name/type, tries each with PROBE_VALUES, reports a key no value satisfies (so the sweep cannot go vacuous by omission), asserts the currency-plus-scale rule is found (anti-vacuity), and drives defineStack with an identity-only column over a field of the namesake type. RIGHT. Reach: single-key type-conditional rules; a rule that needs two extra keys at once would not be found — the one limit of the "reds until it does" sentence (below).

  6. stack.zod.ts: comments only (the diff confirms); the resolution sentence now names objects and drops the data-source-check likeness, matching the code (config.objects only, SKIP on an unresolved column).

Author-shown and AI-facing text, tested sentence by sentence (true unless noted)

  • Registry docblocks (new conversion and respellInlineGridColumns): true. "FormViewSchema is the only schema that declares subforms" — measured, one site. "that entry's carriers stopped accepting field after 17.0.0, and the census pins that published value" — the 17.1.0 census block. "the assembled-manifest viewItems channel carries the last two" — AssembledViewArtifactSchema is the non-container branches. "the parse refuses the mixed shape loudly" — the alias table refuses field whenever it is present, pinned as "field → name". "Idempotent by construction" — true.
  • D3 entry form-view-subform-columns-closed (runtime strings and comment): true on main when this PR lands, since the conversion it names is in the same diff. "rewrites stored rows and assembled artifacts and lists the edit under os migrate meta" — the rehydration seams (stored.ts), the artifact door inside its window, the viewItems walk, and the chain; os migrate meta is the house spelling in ten other runtime strings on main. "a stored row carrying one is diagnosed at rehydration; neither is stripped" — ADR-0087 addendum 2026-07-31 ([metadata_spec_invalid], registered anyway). "while an author writing field meets the refusal" — true.
  • D3 entry inline-grid-column-currency-scale-refused (surface, reach sentence, acceptance): true on main today, since bee75cebe6 (the feat(spec)!: a form view's subform columns are the inline grid column contract, and an identity-only column is judged as the type it renders (#20901) #20927 merge) — this PR corrects text that was false from that commit on. The sibling id resolves. "over a currency field of the child object" — for inlineColumns the column names a field of the object that OWNS the relationship field, and the schema places that field on the child (inlineEdit docblock: "On a child's master_detail/lookup field"), so it is the family's exact term. "defineStack judges that column instead" — reach is narrower than the sentence alone says (only a child object declared in the same stack's objects), which the named sibling entry states; under-stated, not false.
  • stack.zod.ts docblocks: the objectui hydrateColumns / fieldTypeToColumnType claim is now the HISTORICAL citation form (.objectui-sha pin db11afd4967c), which check-objectui-pin-citations lists and accepts without comparing to the current pin; the cited prefix equals the current .objectui-sha on both main and the head (db11afd4967cd9d3…), so it is also true in the asserting sense today. The content of the arm claim is a reading of objectui outside this record's inputs — UNSOURCED here, as in 5919009567, now dated and re-checkable; if wrong the consequence is under-reach only. "inline-grid-column-carriers.test.ts reds until it does" — true for a single-key type-conditional rule, which is what the probe enumerates; a two-key rule would not red it. Slightly over-broad, harmless.
  • Test-file comment "fieldTypeToColumnType maps each of the column schema's nine types' namesake field type to that same column type" — an objectui reading, unsourced within inputs; load-bearing only for a future rule on a non-currency type.
  • Changeset 20901-form-view-subform-columns-canonicalized.md: every sentence tests true. "accepted any value through 17.5.0" — card measurement plus the pending feat(spec)!: a form view's subform columns are the inline grid column contract, and an identity-only column is judged as the type it renders (#20901) #20927 changeset. "A built artifact whose declared protocol floor is 17.5.0 or lower is converted too, not refused" — a floor below the label replays the full chain, a floor of 17.5.0 is at or below retiredAfter and replays this entry, and once the label moves past 17.5.0 that floor is below the label; true at every moment. "the parse names both keys, and the author picks one" — the refusal reads "field → name", pinned. "defineStack and objectstack validate do not replay a retired conversion" — apply.ts. No **BREAKING** banner and no adr-0087 marker — correct: the gate asks a disposition only of a changeset that declares breaking, and the break here was declared and dispositioned by feat(spec)!: a form view's subform columns are the inline grid column contract, and an identity-only column is judged as the type it renders (#20901) #20927's pending changeset (registered the two D3 ids); the new conversion id reaches the release's converted[] from the registry regardless.
  • PR body: "Since bee75cebe6 that is false" — true. "check:objectui-pin-citations lists it" — the gate's population is packages/spec/src and its --list prints both forms; consistent. "Today it finds exactly currency + scale (at 0 and 2)" — follows from PROBE_VALUES and the schema (scale integer 0..100; one superRefine rule); the dev's run, consistent with the tree. "retiredAfter: '17.5.0' is the package label, as retired-after.census.test.ts requires for an unpublished entry" — slightly over-stated: while the label (17.5.0) is ahead of the census's last release (17.4.0) the test tolerates [17.4.0, 17.5.0]; 17.5.0 is the true value either way. "the per-release section of spec-changes.json reports conversion ids that are new in a release" — build-spec-changes.ts's release section. "21 passed", "584 / 17200", "90 derived, 90 run", both ablations and the artifact-door measurement (converted-retired-after) are the dev's own runs, not re-run; the door rule they rest on is verified from its docblock. The "Not changed here" list is accurate (the feat(spec)!: a form view's subform columns are the inline grid column contract, and an identity-only column is judged as the type it renders (#20901) #20927 body sentence, field.zod.ts, finding(spec): ObjectMasterDetailFormPropsSchema.details is z.unknown(), a third unjudged carrier of the inline grid column: a bogus key or a typed currency column with scale publishes green #20928, finding(lint): field-no-consumers calls a field "inert" when an inline grid column names it (form.subforms[].columns[].name), because name is in LITERAL_KEYS #20929, objectui#11266).

② Semver level

Clause-②: no

'@objectstack/spec': patch, title fix(spec):, Clause-②: no with no arm — matches what the diff publishes. No export is added, removed or renamed (the new conversion is a module-private const collected into ALL_CONVERSIONS' value; respellInlineGridColumns is module-private; the type-check family that carries check:api-surface is green). No key is added to any published payload, and nothing narrows: every authoring door refuses field exactly as before, and the data-at-rest seams now accept strictly more — a rescue for stored rows and built artifacts, not a widened authoring surface and not a Clause-② key. Under the "WHICH LEVEL" rule (a purely additive widening of a published public surface takes at least minor; a fix takes patch) this is a fix. The break itself was declared **BREAKING** at minor by #20927's changeset, which is still in .changeset/ on main, so this D2 step lands in the same release as the break — the pre-GA "chain step in the same release" clause of ADR-0087 is met by this PR, and it was not met by #20927 alone (review item 6). The claim's Clause-②: yes (narrowing) described #20927's scope; carrying it here would have been false and would have forced minor. Check Changeset is green on the head.

③ Boundary flags

Dev report 5920703403: no deviations key; two deviations are stated in its summary (the vehicle for item 6; the Clause-② line against the claim's), one open_questions entry and one out_of_scope_findings entry. Seat ruling 5920724494 answered each; this record judges them on its own inputs.

  1. Open question 1 (keep the sibling id A, or fold into field-column-lists-canonicalized B) — ANSWERED: A, for ① item 2. B would put a value the census pins as 17.0.0 in front of a carrier that accepted field through 17.5.0 and reopen, for this carrier, the main-versus-last-release refusal the 2026-09-30 amendment closed. The ruling's intent in 5919009567 (a lossless break lands as a retiredFromLoadPath chain step in the same release) is carried out exactly; only its named vehicle was wrong in detail.
  2. Clause-②: no against the claim's yes (narrowing) — ANSWERED: right for this diff (② above); the claim line was written for feat(spec)!: a form view's subform columns are the inline grid column contract, and an identity-only column is judged as the type it renders (#20901) #20927's scope, and the dispatch told the dev to decide it truthfully.
  3. conversions-major18-merge.test.ts touched outside the dispatched file set — ANSWERED: the smallest true consequence of placing a conversion by the rule beside the list's middle; the change derives the expectation (① item 4). Accepted.
  4. Out-of-scope finding: no STEP18_RATIONALE fragment for the finding(spec): an inline grid column is judged on only one of its carriers — FormViewSchema.subforms[].columns is z.array(z.any()), and a currency column's scale reaches no refusal when its type comes from the child field #20901 family (form-view-subform-columns-closed, inline-grid-column-identity-only-currency-scale-refused, and this conversion). The seat asked whether one is owed. Measured at the head: STEP18_RATIONALE holds 52 fragments against 253 step-18 semantic entries; 207 entries have no fragment of their own id (fragments are per retirement family — duration-keys-unit-in-key covers the unit-in-key entries), and no fragment text mentions the form-view carrier. The list's header asks "one fragment per retirement", and ADR-0087 D3/D4 makes the step rationale the one load-bearing prose the generated guide projects — so the finding(spec): an inline grid column is judged on only one of its carriers — FormViewSchema.subforms[].columns is z.array(z.any()), and a currency column's scale reaches no refusal when its type comes from the child field #20901 family DOES owe one fragment (id form-view-subform-columns-closed, naming the identity-only sibling and the D2 conversion, order one above the highest). No gate requires it (step18-rationale-merge.test.ts pins sort order and the joined text only), the guide projects major 17 only today, and the gap is family-wide backlog rather than this PR's regression. ESCALATED to the domain:spec seat as an owed follow-up before protocol 18 ships; not held against this head.
  5. Console Pin Gate SKIPPED by its paths filter and Build Docs SKIPPED (no docs path touched): the objectui build against this head is NOT MEASURED. No export moved and no type narrowed, so there is nothing for the pinned sibling to lose. Noted, not held.
  6. The objectui readings in stack.zod.ts and the pin file's comment (fieldTypeToColumnType's arms at pin db11afd4967c) are outside this record's inputs — recorded as unsourced here; they are re-checkable at the cited sha and belong to the objectui lane (objectui#11266 follows the next pin bump).
  7. Not re-measured: every count and ablation in the PR body and the dev report, and the dev's artifact-door probe; the check-runs below are the gate verdicts.

Check-runs on 06f079864a (read 2026-09-30 after 22:35Z; 39 runs, 35 names after dedupe keeping the newest started_at): 30 success, 5 skipped — Auto Label, Build Docs (paths), Check PR Size, Console Pin Gate (paths filter), Packed-tarball smoke (opt-in) (label absent) — 0 failure, 0 still running. Green: TypeScript Type Check, Check Changeset, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Type Check (workspace, source gates, consumer gates, debt ledger), Test Core 1-6 and aggregate, Dogfood Regression Gate 1-3 and aggregate, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Build Core, Check Documentation Links, Flag docs affected by code changes, filter, and the four PR-hygiene guards (same issue, same single-writer path, part-of, card claims branch). No governed surface in the file list.

Implemented-by: claude/issue-20901-grid-column-followup
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-30T22:53Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 30, 2026 22:55
@os-justin
os-justin added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit c6b3a01 Sep 30, 2026
44 checks passed
@os-justin
os-justin deleted the claude/issue-20901-grid-column-followup branch September 30, 2026 23:24
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…d the showcase done rate moves to its dataset (objectstack-ai#20943) (objectstack-ai#20998)

Closes objectstack-ai#20943
Clause-②: yes (narrowing)

Ruling D on objectstack-ai#20943 (comment `5921156712`, execution parameters),
dispatched by the claim `5921298734`: an analytics cube member's `sql`
is a column reference, and a SQL expression there is refused at parse
with a prescription naming the ADR-0021 dataset form. The showcase's one
expression member, `done_rate`, moves to its dataset in the same PR.

## What changes

**`@objectstack/spec`, `data/analytics.zod.ts`**

- `MetricSchema.sql` and `DimensionSchema.sql` (every member of a cube's
`measures` and `dimensions`) admit exactly the accept set the execution
parameters name: a bare identifier (`amount`), a dotted identifier path
(`account.amount`, `account.owner.region`), and `'*'`. Any other value
is refused at `measures.METRIC.sql` / `dimensions.DIMENSION.sql` with
code `invalid_format`. That covers a CASE expression, an aggregate or a
ratio of aggregates, a quoted or `$`-prefixed spelling, an empty string
and a broken path. A column reference parses byte-identically to before.
- The identifier half is the pattern the readers already use to tell a
column path from an expression: `IDENTIFIER_PATH` in
`native-sql-strategy.ts`, and the field gate's bare-identifier /
identifier-path pair. So the contract admits exactly the values those
readers resolve to fields.
- The rule is a `.regex()`, not a refinement, so the published JSON
Schema carries it as a `pattern`. A first cut used refinements, and the
build's dropped-refinement gate refused it: a JSON-Schema-validated
document would have been judged differently from the parse. The pattern
closes that gap, and `dropped-refinements.baseline.json` is untouched.
- Each prescription opens with the contract sentence. It names the
dataset form: a measure with its own structured `filter` for a
conditional count or sum, and `derived: { op, of: [...] }` over named
measures for a ratio, sum, difference or product. It also states the
ratio's 0–1 scale. The dimension prescription says that a CASE bucket
has no expression form in either layer.
- Neighbouring prescriptions no longer offer "fold the condition into
the metric's own `sql` expression" as a live channel. This covers the
retired metric `filters` guidance, the analytics query `filters`
guidance, the `metric-filters-removed` conversion summary, its D3 entry
`cube-metric-filters-retired`, and its step-18 rationale fragment. All
of them are unreleased major-18 text.

**ADR-0087.** The D3 entry `cube-member-sql-expression-retired`
(`migrations/entries/semantic/`) and its step-18 rationale fragment were
written after merging a `main` that contains objectstack-ai#20953, which landed as
`c6b3a01d5d`. `registry.ts` was regenerated by `gen:migration-registry`,
never edited by hand. There is no D2 conversion, because an expression
has no mechanical rewrite into a dataset. There is no
`RETIRED_KEYS_BY_MAJOR` row, because no key left the shape.

**Liveness.** The `analytics_cube` rows `measures.sql` and
`dimensions.sql` stay `live`, re-verified 2026-09-30, with the narrowing
recorded and the field gate's `fieldsOfColumnSql` added to the evidence.

**Generated.** Only `content/docs/references/data/analytics.mdx` moved.
The `authorable-surface`, `api-surface` and `json-schema.manifest`
ratchets are byte-identical, as expected for a value narrowing.
`spec-changes.json` and the upgrade guide stay at protocol 17, so
major-18 entries do not project yet, and both checks are green.

**Showcase.** The `showcase_delivery` cube loses `done_rate`. The
`showcase_task_metrics` dataset gains `done_count` (`aggregate:
'count'`, `filter: { status: 'done' }`) and `done_rate` (`derived: { op:
'ratio', of: ['done_count', 'task_count'] }`, `format: '0.0%'`), in the
same shape as the existing `paid_rate`. No dashboard read the cube's
`done_rate`, so there was nothing to re-pin there.
`test/gap-fill.test.ts` is re-pinned: the cube's measure list, the parse
of the shipped cube, and the dataset's filtered-count-over-count form
plus its `DatasetSchema` parse.

**`@objectstack/service-analytics`.** One test and the README, nothing
else:
- `cube-authored-format-granularity.test.ts` built its custom-SQL
fixture with `CubeSchema.parse`, which now refuses it. The fixture is
built unparsed instead, plus one assertion that the parse refuses it.
The engine-path refusal it pins is unchanged.
- `README.md` no longer tells a reader to fold a per-metric condition
into the metric's own `sql` expression. That text ships in the package,
so it gets a `patch` line in the changeset. This file is outside the
claim's declared surface; see the Acceptance notes.

The gate's stand-down branch in `analytics-service.ts` is not touched.
objectstack-ai#20965 remains open for it.

## The fork clause

The fork clause did not fire. The one authored expression member in the
tree is `done_rate`, and its structural equivalent exists and is
measured (next section). A shape-level reading for the seat: a dimension
CASE bucket has no dataset equivalent, because a dataset dimension names
a field and its only bucketing is `dateGranularity`. No authored cube in
the tree carries one. Two `service-analytics` gate fixtures do
(`dimension-source-field-gate.test.ts`,
`where-source-field-gate.test.ts`), but they are built without the parse
to pin the runtime branch objectstack-ai#20965 owns. Out-of-repo cubes are NOT
MEASURED.

## The measure-level filter, verified before relying on it

- **Code path.** `compileDataset` reads `m.filter` into
`measureFilters`. `DatasetExecutor` splits filtered measures off with
`splitMeasuresByFilter` and runs one supplementary query per filtered
measure over the base filter combined with that measure's filter. It
then evaluates `derived` on the merged row, with `ratio` as `a / b` and
null on a zero denominator.
- **Unit pins.** The existing `dataset-executor.test.ts` cases cover a
supplementary query for a measure-scoped filter and a derived measure
over filtered and unfiltered dependencies, and
`dataset-compare-measure-filters.test.ts` covers the number a reader
sees. All are green in the `service-analytics` run below.
- **Live.** An ephemeral showcase boot on a private port (`pnpm dev --
--fresh`, torn down after) queried the task dataset through the
analytics dataset door as the seeded admin. `done_count` and `done_rate`
reconciled against the raw `showcase_task` rows in every priority bucket
and in the ungrouped total. `done_rate` equalled `done_count /
task_count`, and the column carried `format: '0.0%'` and `percentScale:
'fraction'`. The cube's meta listed its three remaining measures.

## Tests (final head `fa979c57ed` unless stated)

- `@objectstack/spec`:
  - `vitest run --project local src/`: 544 files, 16268 passed, 1 todo.
- `--project local scripts/`: 41 files, 950 passed, 1 skipped (at
`a32fd122dc`; the second merge brought no `scripts/` change under
`packages/spec`).
- The relevant `--project repo` files: 10 files, 300 passed. These are
`step18-rationale-merge`, `conversions-major18-merge`,
`liveness/evidence`, `liveness/proof-registry`,
`cube-member-inner-name-retirement`, `cube-refresh-key-retirement`,
`retired-key-migrate-sentence`, `root-index`, `file-description` and
`category-title`.
  - `typecheck` exit 0.
- `@objectstack/service-analytics`: `test` gave 149 files and 3449
passed; `typecheck` exit 0, and the edited test is in its program
(`--listFiles`).
- `@objectstack/example-showcase`: `vitest run` gave 29 files and 387
passed, after rebuilding its closure (60 tasks); `typecheck` exit 0, and
the three edited files are in its program.
- Gates:
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 116 families. All 116 were run, and
`--ran` reconciled them as "116 derived, 116 run, 0 NOT-MEASURED, 0
UNRUN", each with an exit code.
- 115 of them exited 0, including `check:generated`, `check:liveness`,
`check:adr-0087-registration` (reads the new marker),
`check:changeset-no-major`, `check:empty-changeset`,
`check:doc-authoring` and `check:nul-bytes`.
- The other one is `pnpm check:platform-checklist`, which exited 1 on an
anchor this diff does not touch. `areas/identity-auth.json` cites
`plugin-auth/src/auth-plugin.ts#twoFactor`, and that symbol is absent.
This branch's bytes for `packages/plugins/plugin-auth`,
`docs/qa/platform-checklist`, `scripts/check-platform-checklist.mjs` and
`scripts/symbol-anchors.mjs` are identical to the merge base
`05be352596`, so it is `main`'s state, not this diff's.
- Lint, a declared narrowing: `eslint --no-inline-config --format json`
over the 10 changed lintable files gave 10 files, 0 errors and 0
warnings.
- The population comes from `eslint.config.mjs`: the
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. The other changed files
are `.md`, `.mdx` and `.json`.
  - The file count comes from the JSON output.
- Invariance: the config never enables type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move a
verdict on an untouched file. The whole-repo `pnpm lint` is CI's.

## Ablations (each from the committed state; disk-verified through
`scripts/ablation-replace.mjs`; restore proven by blob hash equal to
HEAD and an empty `git diff HEAD`)

| leg | mutation | result under mutation | restored |
|---|---|---|---|
| A1 | the member `sql` pattern admits anything | new spec pin: 9 failed
/ 6 passed (every refusal, door and pattern case red) | 15 / 15 green |
| A2 | the metric `filters` guidance offers the `sql` expression channel
again | 1 failed (the guidance case) | green |
| A3 | the D3 id renamed in the generated registry region | 1 failed
(the registration case) | green |
| A4 | the showcase `done_count` loses its filter | `gap-fill`: 1 failed
(the dataset-form case) | green |
| A5 | the pattern admits anything, then `@objectstack/spec` rebuilt |
`ablation-dist-preflight` marker present in 20 built files; the
service-analytics parse assertion failed (`expected true to be false`) |
rebuilt; marker absent from all 230 files; tree clean; 14 / 14 green |

The expected direction was "turns red" in all five, and that is what was
observed. No ablation file is left in the tree.

## Acceptance notes

- **Surface breach, declared.**
`packages/services/service-analytics/README.md` is outside the claim's
file surface. The dev contract's rule says published text this change
makes false is fixed in the same round, and the dev contract outranks
the dispatch words where they conflict. The edit is one paragraph, and
the changeset carries `'@objectstack/service-analytics': patch` for it.
The seat can drop both if it prefers objectstack-ai#20965 to carry the fix.
- **Not fixed here, governed (Tier H).**
`skills/objectstack-ui/rules/dashboards.md` still escalates past a
dataset to "a hand-authored Cube (raw SQL / explicit joins)" (≈`:78`),
and lists "any custom-SQL metric" as a dataset gap (≈`:70`). Both are
now false: a cube member takes no SQL expression, and joins were already
derived. The natural carrier is the separate Tier H docs PR the ruling
names for ADR-0021's dated note. It is not mixed in here.
- **Not fixed here, internal.**
`docs/qa/platform-checklist/areas/dashboards.json` (the cube meta item,
≈`:976`, `:991`, `:1037`) names `done_rate` among "exactly its four
measures" and as a variant. The cube now declares three. No gate reads
that text. Carrier: the checklist's next revision.
- **Boundary, left as ruled.** `'*'` is admitted on a dimension and on a
non-count measure, because the execution parameters admit it on both
members. Neither was ever a working query. Separately, the `number` /
`string` / `boolean` measure types existed to carry an expression. With
an identifier `sql`, the raw-SQL path emits the column unaggregated and
the ObjectQL path refuses it, so retiring those three types is the
natural follow-up. That is a decision for the seat, not something this
PR does.
- **Not added: a tree-scoped text pin.** The narrowing is enforced at
every parse door. The one authored cube in the tree is parsed at import
(`defineCube`) and pinned by the showcase test. The tree's other
expression members are deliberate unparsed runtime fixtures in
`service-analytics`, owned by objectstack-ai#20965's cleanup. A text scan would have
needed a directory exclusion and a new cross-package radius.
- `tsc` does not catch an expression member, because `sql` is still
`string`; the parse is the judge. Stored `analytics_cube` rows and built
artifacts that carry an expression are NOT MEASURED here.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…osed fragment (objectstack-ai#21090)

Part of objectstack-ai#20901

Clause-②: no

One `STEP18_RATIONALE` fragment in
`packages/spec/src/migrations/registry.ts`: id
`form-view-subform-columns-closed`, `order` 54 (the highest at base
`5dbeb7d7b7` was 53), inserted where its id sorts. It covers the
form-view carrier closure, the D2 conversion
`form-view-subform-columns-canonicalized`, and the identity-only sibling
`inline-grid-column-identity-only-currency-scale-refused`. Each sentence
restates the two entry sources and the conversion source. The list sits
outside the generated markers and is hand-written;
`gen:migration-registry` is a no-op on this diff. A `patch` changeset
for `@objectstack/spec` is included because the rationale string ships
in `dist/migrations`. This is review item 4 of the at-tier record
`5921114874` on objectstack-ai#20953. The card stays open, and the seat closes it by
hand.

## Verification at head `8ebaf237c4` (merges origin/main `dff98c1f51`)

- Rationale pins (`step18-rationale-merge`,
`compliance-families-retirement`, `conversions-major18-merge`): 31
passed. `migrations.test.ts` and `inline-grid-column-carriers.test.ts`:
179 passed. Spec local suite: 588 files, 17318 passed. Spec typecheck:
exit 0.
- `check:generated`: all 15 artifacts are up to date after a spec build.
The upgrade guide prints majors up to `PROTOCOL_MAJOR` (17), so it does
not print step 18 and has no diff here.
- `dispatch-gates --ran`: 82 derived, 82 run, 0 NOT-MEASURED.
- Ablation (fragment removed, anchor 1 to 0, restored to the HEAD blob):
no pin went red. The merge test pins the sort order and the joined text;
no test pins this fragment by id.

## Acceptance notes

- The review counted entries without a fragment of their own id: 207 of
253 at `06f079864a`. Fragments are per retirement family, so the number
of families without one is not counted. `carrier:` none named. Noted
here under the filing gate, with no `reach:` measured today. The
generated guide prints major 17 only (`build-upgrade-guide.ts` loops to
`PROTOCOL_MAJOR`, 17 at this head). Step 18's rationale does reach `os
migrate meta --step`, whose default chain ends at major 18
(`packages/cli/src/commands/migrate/meta.ts`). There, a family without a
fragment is left out of the paragraph, not misstated. This was read from
the source and not measured at that door.

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

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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants