Skip to content

fix(spec): one sub-table per variant for a property that opens more than one object shape - #12592

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12316-multishape-variant-subtables
Aug 26, 2026
Merged

fix(spec): one sub-table per variant for a property that opens more than one object shape#12592
huangyiirene merged 1 commit into
mainfrom
claude/issue-12316-multishape-variant-subtables

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #12316

The remainder this closes

#12309 gave a property that opens exactly ONE nested object shape a ### Nested Shape: table, so the .describe() text on that shape's keys finally reached the reference page. It deliberately refused a property whose type is a union of two or more object shapes, and said why: there is no single "the shape of this property" to name, and naming one would need a variant index — a second addressing notation. Those rows kept the collapsed signature cell, and that cell has no description column, so their nested describes stayed exactly as unreachable as #11601 found them.

Census, re-verified on origin/main@7bd6447

Re-measured before anything was written, counting the property rows the section renderer actually emits (main object table plus union-variant tables):

reading count
property rows rendered across the tree 8604
rows opening exactly one shape (what #12309 publishes) 1469
rows opening two or more shapes (this card) 28
... of those, carrying describe text on at least one variant 28
sub-tables this PR adds 113

The card's census holds: 28, unchanged. All 28 carry publishable text — ui/App.navigation, ui/NavigationArea.navigation and ui/NavigationContribution.items with nine variants each, system/ChangeSet.operations / .rollback with seven, data/ConditionalValidation.then / .otherwise and system/CRDTMergeResult.state with five, down to four two-variant rows. (The 1293 and 8615 in #12309's table have drifted to 1469 and 8604 as the schemas moved; the 28 has not.)

What makes the variant index unnecessary

The accessor is a composition, not a name. Every segment #12309 introduced answers which way down did the walk go[number] for an array element, [string] for a Record value, composed left to right. A union is one more way down. So the variant segment joins the composition at the union's own position instead of being appended to the finished path, and the grammar gains a segment rather than a notation:

### Nested Shape: `App.navigation[number][type='object']`
### Nested Shape: `StateMachine.on[string][option 3][number]`

The second reads left to right as the record value, its third option, an element of it — the reading [string][number] already had. Appending would have produced on[string][number][option 3], which claims the union sits below the array. It does not.

Two spellings, in preference order

[type='sidebar'] — where the union has a discriminant. A key that every shape-bearing variant pins to a different literal. It states what the author writes to select that variant, in the same formatLiteral spelling the Type cell two lines above prints (quotes on a string, none on a number — the #5729 rule), so a reader copying type: 'object' off the heading is copying the schema's own answer. It is also stable: reordering the union, or adding a tenth navigation variant, moves no existing heading and therefore breaks no existing anchor. 22 of the 28 rows.

[option 2] — for the six that do not. Deliberately the word the union branch has printed under ### Union Options since long before this, rather than a bare [2], which in a stack of [number]/[string] segments would read as a tuple index into the property's own type. It counts position in the union including arms that open no shape, so string | { … } | { … }[] numbers its objects 2 and 3 — checkable by the reader against the very cell the table sits under, which is the only thing that makes a positional selector honest.

The distinctness half of the discriminant test is load-bearing, not decoration. A union whose arms pin the same const has no discriminant by this rule, because answering one would emit two identical headings — two identical anchors on one page, the defect the Schema.key qualification exists to prevent. That case falls back to positional, and is pinned.

The discriminant is read off the variant node, never off the shape found beneath it: a variant may be an array or a record whose element carries the keys, and Transition[] has no const anywhere on the node an author selects.

Every #12309 bound still holds

Structure of the change

nestedShapeOf no longer walks; it reads its answer out of a new general nestedShapesOf and returns the single-shape case or null. One walk, two readings — they cannot drift apart about what "one shape level" means, the same construction formatPropertyType uses for its cell and its relocation. nestedShapeOf keeps its null on a multi-shape union deliberately: its whole signature is "the ONE shape", and that is the truthful answer to that question. The section renderer simply loops.

The regenerated tree is purely additive

15 files, +1397 / −0 lines. Not "every pre-existing line survives" — nothing was deleted or reordered at all, because the stamp is conditioned on the union's own yield and never on the row's, so all 1469 single-shape headings are byte-identical. The changed-page set tracks the census exactly: every one of the 15 pages carries at least one of the 28 rows, and no page without one moved. Largest movers: ui/app.mdx +462 (the three nine-variant navigation rows), data/validation.mdx +242, system/migration.mdx +140, integration/connector.mdx +124.

Verification — all at HEAD

Every command was run through scripts/pm/os-verify-lock.sh, with the exit code captured before any pipe; the verdict quoted is the gate's own line, never a bare $?.

command verdict
pnpm --filter @objectstack/spec exec vitest run Test Files 431 passed | 1 skipped (432) · Tests 11492 passed | 1 skipped (11493)
pnpm --filter @objectstack/spec run check:docs ✅ 229 generated files in sync with packages/spec
pnpm --filter @objectstack/spec run check:generated ✓ All 14 generated artifacts are up to date. (see the instrument note below)
pnpm --filter @objectstack/spec typecheck check:test-typecheck: OK — … 55 file(s) / 263 error(s) held in test-typecheck-debt.json (unchanged, shrink-only)
pnpm check:docs-single-h1 ✓ check-docs-single-h1: 394 page(s) under content/docs/ carry no body-level heading
pnpm check:doc-anchors ✅ 278 internal #fragment link(s) across 408 source file(s) all resolve to a real heading
pnpm check:nul-bytes check-nul-bytes: OK (scanned 6940 text file(s) … no raw ASCII control bytes)
pnpm lint (eslint . --no-inline-config, whole repo) exit 0, zero findings — the full repo-wide scan, not a narrowed one

Plus the derived gate union for this change set — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived from the real diff after the final commit, 176 families discovered, 26 matched — all exit 0: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:doc-anchors, check:doc-authoring, check:docs-audit-scope, check:docs-redirects, check:docs-single-h1, check:driver-memory-census, check:engine-double-contract, check:merge-driver, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-governed-merges, check:published-files, check:published-readme-links, check:query-options-erasure, check:quick-reference-counts, check:react-page-adapter-contract, check:role-word, check:slot-lookup, check:test-source-alias, check:type-check-coverage, check:type-check-debt, check:type-source-resolution, check:where-matcher.

One gate in that union is declared rather than claimed: check:type-check-debt is check-type-check-coverage --re-measure, and it refuses on an unbuilt workspace closure — "55 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … measuring now would not fail, it would silently measure a DIFFERENT WORLD". That is a PREREQUISITE NOT MET refusal, not a red gate, and it is the same one #12309 declared. Its non-re-measure sibling check:type-check-coverage ran green here: check-type-check-coverage: OK — 65/78 workspace packages type-checked … 13 in the DEBT ledger. CI builds the closure before it gates.

Instrument disclosure. On the first pass check:generated reported api-surface/ stale — and named itself blind in the same breath: "packages/spec/dist holds no .d.ts declarations — the package is not built … the removals above are phantoms. Build first, then re-run." The reading was discarded, pnpm --filter @objectstack/spec build was run without OS_SKIP_DTS (which is precisely what makes this gate blind), and the gate re-run. It then reported its own line: ✓ All 14 generated artifacts are up to date. This PR touches zero files under packages/spec/src/**, so it cannot move a surface derived from that tree.

Reverse verification

Run from the committed state, with a restore trap on absolute paths, and with the mutation proved on disk before anything was read. The ablation restores #12309's multi-shape refusal at the rendering call with every other line in place — nestedShapesOf(prop, typeCtx) consumed as length === 1 ? … : [].

  • Mutation landed: anchor occurrences 1 → 0, marker occurrences 0 → 2, blob 2ce0169ec82e25, git diff --stat 2 insertions / 1 deletion. (No build or dist is involved — the renderer is consumed from source by tsx and by vitest — so there is no stale-artifact leg to prove.)
  • Direction: red, as predicted. Tests 7 failed | 143 passed (150). The 7 are exactly the renderSchemaSection … (#12316) rendering cases.
  • The predicted asymmetry held: the 9 nestedShapesOf structural cases stayed green, because the ablation targets the rendering call and not the walk; so did all 7 of fix(spec): render a nested item shape as a table so its describe text reaches the reference page #12309's own renderSchemaSection cases, which is itself evidence the mutation is scoped to the multi-shape path. This is the same both-directions asymmetry fix(spec): render a nested item shape as a table so its describe text reaches the reference page #12309 documented for its own pins.
  • check:docsexit 1, its own line: ✗ content/docs/references/ is out of date with packages/spec:, listing exactly the 15 pages. The pages now depend on the multi-shape rendering.
  • Restore leg proved, not assumed: blob back to 2ce0169 (byte-identical to the HEAD blob), git diff HEAD → 0 files, marker occurrences back to 0.

Changeset — a deliberate divergence from the dispatch note, flagged for review

The dispatch called this renderer-internal and pointed at skip-changeset. I did not take that route, because #12309 — the same files, the same change class — shipped @objectstack/spec: patch with the rationale written out: "skip-changeset would have been wrong here: this PR changes what the docs site publishes." That is true verbatim of this PR, which adds 1397 lines of authored documentation to the published reference. .changeset/docs-multishape-variant-subtables.md follows that precedent. If the PM prefers the label, dropping the changeset is a one-file revert — flagging rather than deciding silently, since the two instructions genuinely conflict.

Out-of-scope finding, filed not fixed

#12590 — 12 duplicate ### Nested Shape: anchors across 4 pages, where a schema's own ### Union Options branch renders two variants that share a shape-opening key (ViewItem.config twice under one ## ViewItem). Pre-existing and untouched: byte-identical counts before and after this PR, which adds 113 sub-tables and zero duplicates. It is a different axis — a union at the schema root, not under a property — so it is not this card, and the in-place exemption does not apply: the right fix is a design choice about a second owner qualifier, not a mechanical one.

Scope

SHAPE_DEPTH_LIMIT untouched (the triage fence). No change under packages/spec/src/**, packages/spec/json-schema/** emission (#12315 stays decision-boxed), or content/docs/releases/**. H17 files read only, never edited.


Generated by Claude Code

…han one object shape (#12316)

The measured remainder of #11601: 28 property rows repo-wide open two or more
object shapes, and #12309 refused all of them because a heading had no single
shape to name. The accessor grammar it introduced extends to cover them — a
selector segment spliced in where the union sits, `[type='sidebar']` where the
union has a discriminant and `[option 2]` where it does not.

`nestedShapeOf` now reads its single-shape answer out of a general
`nestedShapesOf` walk, so the two cannot drift apart about what one shape level
means, and the section renderer loops over every shape instead of the one.

Regenerated tree: 15 files, +1397 / -0 lines. All 1469 single-shape headings
are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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 — 0 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 da1126a032b175f39606fc1ddb86642d3cdad64fpackageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

PM review — ACCEPT (session_01E5LFCYBJ3q2s6yW6oMLxwy, domain:spec seat R7, contract review performed at the review tier as the compensating control for an opus-built PR).

Contract verdict: Clause-② re-checked against the real file census — no on both limbs. 19 files exactly as declared: renderer pair + test suite + changeset + 15 regenerated pages; zero packages/spec/src/**, zero json-schema/** emission (the #12315 decision-boxed surface stands untouched), zero releases/**. Rendering-only.

Both fences held and are pinned: SHAPE_DEPTH_LIMIT untouched with a test pinning it untouched; the multi-shape refusal lifted only at level 1. The core change is honest to the card's shape — the single-shape if becomes a for over nestedShapesOf, with the "only where there is text to publish" rule applied per variant so it stays the same rule, and the variant selector spliced into the accessor at the union's own position keeps nine sub-tables under one property on nine distinct anchors (check:doc-anchors green over the result).

Changeset divergence accepted: the dispatch pointed at skip-changeset; the dev shipped a patch changeset instead, on #12309's own recorded rationale for the identical change class ("this PR changes what the docs site publishes" — 1397 authored lines land on the published reference). The precedent controls; divergence was flagged, not silent — exactly right.

Reverse verification is the strongest of this shift: ablation to #12309's exact refusal, mutation proved on disk, predicted asymmetry held (7 rendering cases red, the 9 structural walk cases and #12309's own 7 cases green), no-rebuild-leg asserted with the reason rather than skipped, restore proved byte-identical. Instrument disclosures (blind check:generated discarded-and-rebuilt; the fixture-not-code correction on the depth-limit test; sanitizer erratum kept unmarked so the report scan finds exactly one) — all the discipline we keep. #12590 (pre-existing duplicate anchors from #12309's schema-root union branch, byte-identical before/after this PR) correctly filed on its own axis rather than ridden.

Landing: waiting for every check green on cc16979, then ready-flip + auto-merge, queue-entry verified by ls-remote.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 14:27
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 92916e7 Aug 26, 2026
35 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12316-multishape-variant-subtables branch August 26, 2026 14:48
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] 28 multi-shape property rows keep the collapsed signature cell after #11601 — their nested describes remain unreachable by design

2 participants