Skip to content

fix(spec): reach all three persisted view spellings in every view-family conversion - #13054

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-13031-view-conversion-walker
Aug 29, 2026
Merged

fix(spec): reach all three persisted view spellings in every view-family conversion#13054
os-trump merged 3 commits into
mainfrom
claude/issue-13031-view-conversion-walker

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#13031

ViewMetadataSchema accepts three persisted view body shapes and all three land in sys_metadata rows, but every view-family conversion walked only the container keys. For a stored ViewItem record or a flattened overlay the entire chain replayed by applyConversionsToStoredItem('view', row) was a no-op — the row kept its historical shape while the conversion layer reported it canonicalized, and the rehydration parse then refused exactly what had never been rewritten. That is the "row that once worked breaks with no author in the loop" case stored.ts's module doc says the replay exists to prevent.

Premise, re-measured on the base commit (8cb96ec)

All three halves of the card hold:

  • ViewMetadataSchema (packages/spec/src/ui/view.zod.ts) publishes VIEW_METADATA_BRANCHES = ['viewItem', 'container', 'listOverlay', 'formOverlay'] — the container, the ViewItem record, and the flattened overlay in its list and form arms.
  • applyConversionsToStoredItem('view', item) (packages/spec/src/conversions/stored.ts) replays the full chain, includeRetired: true, over { views: [item] }.
  • Exactly five mapCollection(stack, 'views', ...) call sites exist in registry.ts, and they are exactly the five conversions the card names. Each read only view.list / view.form / view.listViews / view.formViews. Enumerated by reading the chain, not from the dispatch list — the grep for view-collection call sites returns those five and nothing else.

The gap, reproduced first

The pin file was written and run against the unmodified chain before any implementation:

Test Files 1 failed (1)
Tests 16 failed | 6 passed (22)

The 6 green were the container legs and the negative shapes; every ViewItem-record and flattened-overlay case was red.

The walker

mapViewPayloads in packages/spec/src/conversions/walk.ts, following the region-slots.ts centralization precedent in the same directory family: one walk, so reach cannot differ per conversion. It hands each conversion the list/form payload wherever it lives, labelled with its family.

Discrimination uses ViewMetadataSchema's own discriminators, in the union's own member order, so the walk and the parse cannot disagree about what a body is:

SpellingDiscriminator (the schema's, not a heuristic)Where the payload is
ViewItem recordviewKind present andconfig is an object — member 1's discriminated unionconfig
Containerat least one of list/form/listViews/formViews — the containerHasAView refinementeach present slot
Flattened overlayviewKind present, config absent, no container slot — the config/list/form/listViews/formViews guards pinned to undefined on both inline armsthe top-level body itself

A body matching none of the three passes through untouched — this walker never manufactures shape.

The list-or-form question the card left open is not ambiguous, and the schema answers it. Members 3 and 4 are literally ListViewSchema.extend(flattenedViewOverlayFields()).strip() and FormViewSchema.extend(...), so "the top level is the payload" is what the schema declares rather than something inferred; which family it is comes from viewKind, which #7741 made REQUIRED on both arms precisely because the object-bound read paths match on object + viewKind. No key needed a judgement call, so nothing is guessed here.

The family label is load-bearing, not informational. These conversions are shape-scoped and two of them strip a key that is inert on one family and live on the other — aria is retired on a form and live on a list, data the reverse. A walk that could not tell the two apart would delete live keys. view-inert-keys-removed selects its key set from the label, and a test pins that a list record keeps its aria.

Adoption table — conversion x spelling x pinned-by-test

Every cell is pinned by a case in packages/spec/src/conversions/view-spelling-walk.test.ts; container cells additionally keep their pre-existing fixture in registry.ts, unchanged.

ConversionContainerViewItem recordFlattened overlay
view-visibleOn-to-visibleWhen (form)pinned (fixture + parity case)pinned, notice path assertedpinned, notice path asserted
view-inert-keys-removed (both families)pinned (fixture)pinned, list arm and form arm separatelypinned, list arm and form arm separately
view-list-passthrough-keys-removed (list)pinned (fixture)pinnedpinned
view-export-options-pdf-removed (list)pinned (fixture)pinned, array and object spellingspinned
form-view-option-default-removed (form)pinned (fixture)pinned, nested composite rowspinned

Plus: a cross-spelling agreement case asserting all four container/record/overlay routes produce the identical payload; an overlay identity case pinning that no key any view conversion strips collides with name/object/viewKind/label/columnState/isDefault/order/scope/owner (so a later conversion whose key does collide fails loudly rather than deleting a row's binding); an idempotence case; and a copy-on-write case asserting the same row reference comes back when nothing converts.

Contract section

Clause-② PATH leg is hit — the diff lives in packages/spec/src/**.

CONTENT reading: the accept set does not move. This is data-at-rest canonicalization catching up to shapes ViewMetadataSchema had already ruled on. No schema is edited, no member's strictness changes, no key becomes newly authorable or newly refused. What changes is only which stored bodies the rewrite layer reaches before a parse sees them — strictly more rows arriving canonical, never a body newly accepted or newly rejected. Evidence that the surface really did not move: check:authorable-surface, check:api-surface, check:docs, check:export-origins and check:upgrade-guide are all green with no regeneration (all 14 generated artifacts reported up to date), and the container legs, including every notice path, are byte-identical in behaviour — their fixtures in registry.ts are untouched and still pass.

Blast radius: explicitly UNMEASURED

The card asked this to be stated either way, and the honest answer is that nothing bounds the real-deployment population of pre-protocol ViewItem-record or flattened-overlay rows:

  • The walker gap is measured — code shape, and reproduced as 16 failing cases before the fix.
  • The deployment population is not. Probed for anything in-repo that could bound it: zero occurrences of viewKind anywhere under examples/, and the CLI migrate-meta e2e covers no ViewItem-record row. So no fixture, seed or test in this repo carries a stored view row in either new spelling, and nothing here can say how many such rows exist in real sys_metadata tables.

Sizing this would need a query against real deployments, which is outside a spec change. The fix is worth landing regardless of the count, because its cost is bounded (the container path is provably unchanged) while the failure it removes is silent.

Verification

Union run on the final commit 13e03bd4a.

  • pnpm --filter @objectstack/spec testTest Files 441 passed | 1 skipped (442), Tests 11711 passed | 1 skipped (11712)
  • pnpm --filter @objectstack/spec typecheck — all three legs green; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json. Coverage proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists all three edited files, with zero errors attributed to any of them.
  • pnpm --filter @objectstack/spec check:generatedAll 14 generated artifacts are up to date.
  • pnpm check:type-check-debt31 ledger entr(ies) re-measured, 1570 raw tsc error(s) total, none above its recorded number. surplus: none
  • Consumers of the stored seam: metadata-protocol 2010 passed / 10 skipped, metadata 674 passed, metadata-core 260 passed, CLI migrate-meta.e2e 14 passed
  • pnpm lint (full repo, eslint . --no-inline-config) — exit 0, no narrowing claimed
  • pnpm check:nul-bytes green, plus a direct control-character scan over all four changed files (no matches)
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack and re-derived on the final tree; all 35 named families run green. Two returned PREREQUISITE NOT MET on first run and are reported as such rather than as passes: check:doc-formula-expressions (needed @objectstack/formula and @objectstack/lint built — built them, then green) and check:dev-prereqs (needed the workspace closure — built it, then green). check:half-states remains a non-reading: the container's GitHub token is a proxy placeholder, so the gate swept nothing. That is no reading, not a green.

Ablation — the new fixtures are load-bearing

Predicted before running: cutting the flattened-overlay leg out of mapViewPayloads reds the 7 flattened cases plus the cross-spelling agreement case, and leaves the record and container legs green — 8 red, 14 green.

Observed exactly that, on exactly those 8 cases. The mutation was confirmed on disk rather than trusted from an editor exit code: the removed anchor text went 1 occurrence to 0, the injected marker 0 to 1, and the blob hash moved off the HEAD blob (guarded so a zero-match edit aborts the run). Restore was proved the same way, not by an exit code — restored blob hash equal to the HEAD blob, git diff HEAD empty, git status --porcelain empty, marker back to 0 occurrences — and the restored run is green again at 22/22. No rebuild was involved on either leg: these tests import the conversion source directly (./stored.js resolved to src/ by vitest), so no dist/ sits between the mutation and the reading.

Files

packages/spec/src/conversions/walk.ts (the walker), packages/spec/src/conversions/registry.ts (five adoptions), packages/spec/src/conversions/view-spelling-walk.test.ts (new), one changeset. Nothing outside the dispatched surface.


Generated by Claude Code

…ngs (red)
Reproduces #13031: 16 of 22 cases fail — every view-family conversion
walks only the container keys, so a stored ViewItem record or flattened
overlay escapes applyConversionsToStoredItem('view', ...) entirely.
The 6 green cases are the container legs and the negative shapes.
…ily conversion
Adds mapViewPayloads to conversions/walk.ts — one shared walk that
discriminates the container, the ViewItem record (viewKind/config) and the
flattened overlay by ViewMetadataSchema's own discriminators, and hands each
conversion the list/form payload wherever it lives, labelled by family.
Adopted by all five view-family conversions. Before this, each walked only
the container keys, so a stored ViewItem record or flattened overlay escaped
applyConversionsToStoredItem('view', ...) entirely.
Fixes#13031
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/object-extensions.mdx(via listViews (literal))
  • content/docs/getting-started/build-with-claude-code.mdx(via listViews (literal))
  • content/docs/kernel/contracts/metadata-service.mdx(via listViews (literal))
  • content/docs/kernel/services-checklist.mdx(via listViews (literal))
  • content/docs/protocol/kernel/i18n-standard.mdx(via formViews (literal), listViews (literal))
  • content/docs/protocol/kernel/plugin-spec.mdx(via formViews (literal), listViews (literal))
  • content/docs/ui/actions.mdx(via listViews (literal))
  • content/docs/ui/create-vs-edit-form.mdx(via formViews (literal))
  • content/docs/ui/forms.mdx(via formViews (literal), listViews (literal))
  • content/docs/ui/public-data-collection.mdx(via formViews (literal))
  • content/docs/ui/views.mdx(via formViews (literal), listViews (literal))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 126 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 8f10a79f7aef471482fdb4761ae150014a7f355apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 59de31d472d8d09584159369c4d7a4eb147b6e74 — the merge of head 13e03bd4a8820e1e5119b733b80d533526cbc59b into base 8f10a79f7aef471482fdb4761ae150014a7f355a, 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 59de31d472d8d09584159369c4d7a4eb147b6e74 && git checkout 59de31d472d8d09584159369c4d7a4eb147b6e74
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8f10a79f7aef471482fdb4761ae150014a7f355a 13e03bd4a8820e1e5119b733b80d533526cbc59b && git checkout -B drift-repro 8f10a79f7aef471482fdb4761ae150014a7f355a && git merge --no-ff 13e03bd4a8820e1e5119b733b80d533526cbc59b
node scripts/docs-audit/affected-docs.mjs --json 8f10a79f7aef471482fdb4761ae150014a7f355a

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@claude