Skip to content

fix(spec)!: a flattened view overlay is judged by the member its viewKind names (#20186) - #20245

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20186-view-overlay-viewkind-arm
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20186-view-overlay-viewkind-arm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20186
Clause-②: yes (narrowing)

A flattened view overlay is now judged by the overlay member its viewKind names. A column-less list patch, which the console writes on every toolbar save, has its list keys judged, instead of being accepted by the form member with those keys stripped unread. This is route C-prime as ruled in seat answers 5855433719 and 5855548706 on #20186.

What was wrong (measured)

Both flattened overlay members of ViewMetadataSchema shared one viewKind: 'list' | 'form' enum. The list member required columns, so it refused a column-less viewKind: 'list' body. The union then tried the form member, which requires no list key and .strip()s every one, and accepted the body: diagnoseViewMetadata answered formOverlay and the parse output was type: 'simple', a form.

I measured this through the real saveMetaItem on origin/main 4df101c3, and again at the spec level on ce70876e after #20183 landed; the verdicts were byte-identical. Each of these bodies answered success: true, and the row held it exactly as sent:

  • { name, object, viewKind: 'list', sort: 'name desc' }, a retired sort string;
  • the same body with timeline: { …, metaFields: [...] };
  • the same body with searchableFields: 'name'.

The mirror also held: the list member accepted { viewKind: 'form', columns: ['name'] }.

The column-less list body is not malformed; the console writes it. objectui's buildPersistedViewBody returns { ...patch, viewKind } for an overlay, and updateViewConfig stamps object, name and _isOverride. That is the maintainer ruling on #7494 (comment 5261754173): 「persistViewPatch 只存 patch,不存 merged base」. So refusing it (route A) was ruled out.

What changed — packages/spec/src/ui/view.zod.ts, the flattened-overlay region only

  • flattenedViewOverlayFields(kind): one viewKind per member. The list member admits 'list' only and the form member 'form' only. An absent viewKind keeps the authoring-validation-not-persisted: a flat view body is accepted, published and reported valid, then expands to nothing — the write door judges by the wire union, not the strict ViewSchema #7741 binding prescription.

  • The flattened LIST member judges a patch. columns is optional on this member only; the authoring ListViewSchema keeps it required. type is declared here without its .default('grid'), so the member's checks can see whether the body named one. Three checks follow on the same object schema, in this order:

    1. checkListOverlayTypeNeedsColumns refuses a column-less body that names a type, at columns. The issue is aborting, so the union envelope stays invalid_union.
    2. The existing calendar check.
    3. applyListOverlayTypeDefault (an .overwrite()) puts type: 'grid' back, in the key position the default took. FormViewSchema already uses the same .overwrite() shape for its groups → sections fold.

    No pipe: both overlay members are still def.type === 'object', and a test pins it.

  • The FORM member's columns is a clone of FormViewSchema's own count schema with an error map added. Its constraints are that schema's, and a field list gets a prescription. The member is built with .safeExtend(), because overriding a key on FormViewSchema, which carries refinements, is refused by .extend(). The two differ in that throw only.

  • diagnoseViewMetadata and selectViewMetadataBranch needed no change. Each body is now accepted by at most one overlay member, so the branch they name is the member that judged it. The conversions walk (mapViewPayloads) already picked an overlay's family from viewKind; the parse now agrees with it.

Refusal and diagnosis texts (new)

LIST_OVERLAY_TYPE_NEEDS_COLUMNS (at `columns`, code custom)
This list view overlay sets `type` but lists no `columns`. A body that sets `type` is a full inline list config, and a full config lists its columns: add `columns: ["field_a", "field_b"]`. A patch on the view it shadows (`sort`, `hiddenFields`, `columnState`, `inlineEdit`, …) sets no `type` and needs no `columns`: remove `type` to save this body as a patch on the view it shadows.

FORM_OVERLAY_COLUMNS_IS_A_COUNT (at `columns`, code invalid_type)
On a form view `columns` is the NUMBER of body columns (an integer, 1 or more), not a list of fields, and this body says `viewKind: "form"`. A list of fields is a list view's `columns`: if this is a list view, set `viewKind: "list"`; if it is a form, list its fields in `sections: [{ fields: [...] }]`.

overlayViewKindArmMismatch(kind) (at `viewKind`; seen only on a direct member parse, the union mutes the unclaimed member)
This is the flattened FORM overlay member, which judges `viewKind: "form"` only. A `viewKind: "list"` body is judged by the list overlay member (`VIEW_METADATA_MEMBERS.listOverlay`), and that member's issues are its diagnosis.

Served JSON Schema (/api/v1/meta/types/view)

The schema is still an anyOf of four members in both directions. Members 0 and 1 are byte-identical. I diffed it against ce70876e, and it moves only by the contract:

  • member 2 (list overlay): viewKind.enum is ["list"], and columns is no longer in required. In the output direction type is no longer in required. The type default grid stays byte-identical in both directions, via .optional().meta({ default }).
  • member 3 (form overlay): viewKind.enum is ["form"]. columns is unchanged.

Pins: ViewMetadataSchema AND saveMetaItem

  • New: packages/spec/src/ui/view-overlay-viewkind-arm.test.ts (schema + diagnoseViewMetadata).
  • Added to packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts (its ledgered stub engine, the real saveMetaItem, and the stored row read back). Every refusal asserts INVALID_METADATA, 422, nothing stored, and the issue's path, code and prescription.
body (flattened, object bound) was now
viewKind: 'list', sort: [{ field, order }] (the headline) ACCEPT on formOverlay, type: 'simple' ACCEPT on listOverlay, type: 'grid', stored verbatim
the objectui sort / hiddenFields / inlineEdit / columnState / rowHeight patches ACCEPT on formOverlay ACCEPT on listOverlay, stored verbatim
viewKind: 'list', sort: 'name desc' ACCEPT, stored REFUSE invalid_type at sort (the 17.5.0 prescription)
viewKind: 'list', timeline.metaFields ACCEPT, stored REFUSE unrecognized_keys at timeline
viewKind: 'list', searchableFields: 'name' ACCEPT, stored REFUSE invalid_type at searchableFields
viewKind: 'list', sharing: { enabled: true } (form block) ACCEPT, stored REFUSE unrecognized_keys at sharing
viewKind: 'form', columns: ['name'] (mirror) ACCEPT on listOverlay REFUSE on formOverlay, invalid_type at columns, count prescription
viewKind: 'list', type: 'kanban', no columns (overlay.list.identity) REFUSE (invalid_union, bare Invalid input at columns) REFUSE (invalid_union), custom at columns with the prescription
control: real form overlay ACCEPT ACCEPT, parse output byte-identical
control: list overlay with columns ACCEPT ACCEPT, parse output byte-identical

Pin sweep

  • Flipped on purpose: view-union-diagnostics.test.ts put.isPinned, put.sortOrder and put.pinAndOrder. The verdict is still ACCEPT, but the pinned parse output moves from { type: 'simple', … } to { type: 'grid', … }. The seat named these three as the intended change.
  • Not flipped: overlay.list.identity, which stays REFUSE and stays invalid_union.
  • No pin anywhere asserted a W1 or W2 body refused. The swept suites all ran green unchanged, apart from the three put.* pins above (numbers under Verification).
  • The fix(spec)!: judge a flattened list view overlay's legacy options bag at the view write door (door half of #20051) #20183 pin in view-overlay-options-bag.test.ts (a column-less body with a BAD options bag stays refused) holds: the list member judges the bag.

Readers keyed on the old form parse of these rows

  • graftNormalizedOperators (the saveMetaItem operator graft). On 805af4f2 the form member stripped a column-less list patch's filter, so an alias operator (eq) was stored as written. Now the list parse normalises it, and the graft writes the canonical operator into the row, as it already did for a list overlay with columns. Measured through the real save on this branch: filter: [{ field: 'name', operator: 'eq', value: 'x' }] is stored as operator: 'equals'.
  • graftFoldedFormSections. On 805af4f2 a list patch carrying the form key groups was parsed as a form (parse output sections: [...]), and the graft stored it as sections. Now the list member drops groups unread, and the row keeps groups as authored (measured through the real save). This is a W1-class body; see the Acceptance notes.
  • objectui, at the pin and at main, has no reader of diagnoseViewMetadata or of the parse output's type for these rows. Its only hits are docblocks naming VIEW_METADATA_MEMBERS.formOverlay, for real form overlays.

Declared widening (W1, W2) — why Clause-②: yes

Both classes are column-less, type-less viewKind: 'list' bodies that were refused and are now accepted. All readings below are on 805af4f2 and on this branch.

  • W2: a list-legal value under a key both members declare with different schemas.
    • Measured body: { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', aria: { ariaLabel: 'Leads' } }. It was REFUSED and is now ACCEPTED on listOverlay.
    • The class also covers an i18n description, the list sharing block and a valid legacy options bag. Each of those is pinned ACCEPT at both doors, and aria is pinned too.
    • The divergent keys, measured off the members: type, columns, description, sharing, aria, options (and viewKind itself).
    • This is the ruling working: a list body is judged by list rules.
  • W1: an invalid value under one of the 19 form-only keys.
    • Measured body: { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', isPinned: true, layout: 'diagonal' }. It was REFUSED, because the form member judged layout. It is now ACCEPTED, with layout dropped from the parse.
    • ⛔ It is not pinned as desired behaviour; see the Acceptance notes.
    • The 19 keys: layout, title, defaultTab, tabPosition, allowSkip, showStepIndicator, splitDirection, splitSize, splitResizable, drawerSide, drawerWidth, modalSize, sections, groups, subforms, defaultSort, submitBehavior, buttons, defaults.

Census (literal and dynamic producers)

  • objectstack 4df101c3: 236 literal viewKind: 'list' occurrences in 94 files under packages/**. Of those, 56 are column-less flattened: 41 tests, 4 changelog lines, and 11 comments or docstrings. Control: 105 flattened-with-columns and 70 config records were classified by the same scanner.
  • examples/**: 0 viewKind at all. Control: 15 files with listViews and 14 with defineView.
  • objectui: at the pin f8a9d0fb, 90 occurrences in 39 files; at main 25c7d584e, 95 in 42. The column-less ones are tests plus 4 type declarations or docstrings.
  • cloud main 48d7066: 60 occurrences in 13 files; all 14 column-less ones are tests. Its two producers emit config records.
  • Literal source producers: 0 in every tree. The one real producer is dynamic and was found by reading the code: objectui's buildPersistedViewBody plus updateViewConfig, called from the sort, hiddenFields, inlineEdit and columnState toolbar handlers on objectui main. It keeps saving.
  • No body that was accepted is refused for that producer, so no ADR-0087 D2 conversion is needed.
  • The semantic entry view-overlay-judged-by-viewkind-arm records the write-time refusals, and the registry is regenerated.
  • Stored rows in deployments: NOT MEASURED (no deployment store is reachable).

Verification

All heavy runs went through os-verify-lock. The lock is shared, so every timing is a shared-box reading.

Pre-merge, at 750d0c82 (the branch merged with 805af4f2), in one locked batch (VERDICT command-exit 0, held 984s):

  • Build of the metadata-protocol, objectql, lint and rest dependency closures (the ^... filter): exit 0.
  • spec check:generated: exit 0, all 15 artefacts current, api-surface/ included, so no generated artefact moves.
  • spec vitest --project local: 545 files passed, 16059 tests passed, 2 todo.
  • metadata-protocol vitest: 189 files passed, 3 skipped; 2715 tests passed, 19 skipped. metadata-protocol typecheck: exit 0.
  • Consumer suites, every test file in the package that names viewKind: objectql 24 files / 380 tests, lint 8 files / 349 tests, rest 11 files / 153 tests. All passed.
  • spec vitest --project repo: 32 files / 586 tests, measured at 87f9d156 in an earlier locked batch. After that, packages/spec changed only by a type annotation (e272672a). spec typecheck exited 0 on those bytes.

Post-merge, at 859730ef (merged with c02fa127):

  • The merge brought feat(spec)!: retire the view item's owner and hidden keys (ADR-0049) #20227 (view-item owner/hidden retirement, a disjoint region of view.zod.ts) and others. It merged clean, and the migration registry regenerates byte-identical.
  • The locked rebuild plus check:generated, spec local vitest and metadata-protocol vitest is queued. Until it runs it is NOT MEASURED here, and the dev report carries its reading.

Gates. I re-derived the list on the actual paths (dispatch-gates.mjs --commands: 88 families) and ran every line, plus the 5 artefact-roster gates whose roster sits in a directory this diff touches. Exit codes went to disk before anything was read.

  • --ran reconciliation at 859730ef: 88 derived, 81 run green, 7 NOT-MEASURED (exit 3, PREREQUISITE NOT MET), 0 unrun.
  • Five of the seven (spec check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any) read the built dist. They were green at 750d0c82, and the post-merge rebuild re-reads them.
  • The other two (check:dual-build-cjs-loads, check:type-check-debt) need the whole-workspace build and are declared to CI.
  • The 5 roster gates also ran green.
  • check:engine-double-contract and check:objectql-double-limit flagged the stub engine my first save-door test file declared. I moved those pins into the existing, ledgered double in protocol.graft-folded-form-sections.test.ts, and both gates are green: no ledger change.

Consumer notes.

  • objectui's own suites run against its pinned spec version and were not run here.
  • examples/** carries no viewKind at all, so it has no objectui-facing overlay fixture to sweep.

Acceptance notes

  • W1, a same-family residual, not filed here. A column-less list patch carrying an invalid form-only key is now accepted with the key dropped unread.
    • reach: measured at the public door, saveMetaItem (the PUT /api/v1/meta/view path), on this branch: { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', isPinned: true, layout: 'diagonal' } answers success: true, and the row holds layout: 'diagonal' as sent (a throwaway probe, never committed). The same save with columns: ['name'] added answers identically.
    • Control on origin/main 805af4f2: { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', columns: ['name'], layout: 'diagonal' } is ACCEPTED on listOverlay, with output {"name":"crm_lead.all","type":"grid","columns":["name"],"object":"crm_lead","viewKind":"list"} (layout dropped). So this change routes more bodies to the list member's existing handling of undeclared keys; it does not create that handling.
    • F2 (refusing the 19 form-only keys by name on the list member) was not taken on this card, per seat answer 5855548706. The seat decides filing at ACCEPT.
    • Dedupe words: list overlay form-only key stripped · flattened list overlay layout dropped unread · W1 form key on list patch.
  • The graftFoldedFormSections behaviour change above (a list body's groups is no longer folded to sections) is the same residual seen from the save path.

Generated by Claude Code

…-prime premise probe)

The form overlay member admits viewKind form only; the flattened LIST
overlay member makes columns optional, refuses a column-less body that
names a type (located at columns), and re-applies the grid default with
an overwrite check after the refusal reads the input side. Both members
stay plain object schemas (no pipe). Work in progress: tests, changeset,
ADR-0087 entry and generated artefacts are not in this commit.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…so the union envelope keeps invalid_union

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…Kind names; pins, ADR-0087 entry, changeset

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…a patch on the view, not a personalization

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…or on the existing ledgered double

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/protocol/objectui/index.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/protocol/objectui/layout-dsl.mdx (via field_a (literal, a string literal in LIST_OVERLAY_TYPE_NEEDS_COLUMNS), field_b (literal, a string literal in LIST_OVERLAY_TYPE_NEEDS_COLUMNS))
  • content/docs/ui/forms.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
What this run could not see
  • 4 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 — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ab820016b3e9691870e24a9bbb867336ee5e8f72 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json ab820016b3e9691870e24a9bbb867336ee5e8f72

⚠️ 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 ab820016b3e9691870e24a9bbb867336ee5e8f72 → 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: 859730effd0edc19c642a77ce03f627837267110

① Derived judgments

  • Method. Read the card (spec(ui): a flattened viewKind: 'list' view overlay with no columns is refused by the list overlay member, then ACCEPTED by the form overlay member, so its list keys are stripped unjudged and a wrong 200 stores it #20186, all 8 comments, incl. 5854135942, 5855433719, 5855518578, 5855548706, 5855239789 amended to Clause-②: yes, 5856666509, 5856684897), the updateViewConfig stores "per-view personal config" as ONE org-wide metadata item — sort/hiddenFields/columnState/rowHeight written by any user apply to every user of the view #7494 ruling 5261754173, spec(ui)+metadata save: judge a flattened view overlay's top-level options.KIND with the strict per-kind schema and persist the PARSED body — the door half of objectui#10380 #20051 (all 9 comments incl. pointer 5856685597), the PR body/diff/check-runs, objectui at pin f8a9d0fb and cloud main. Executed probes in a detached worktree at the head (pnpm install --frozen-lockfile, exit 0): a 76-body corpus plus 18 edge bodies run with tsx over packages/spec/src at origin/main (ab820016, staged from git archive) and at the head; z.toJSONSchema in both directions at both; the PR's save-door file plus a throwaway real-saveMetaItem probe on the same ledgered engine double (metadata-protocol closure built JS-only, OS_SKIP_DTS=1, exit 0). Probe scripts and raw outputs sit beside this record (probe.mts, probe2.mts, probe3.mts, compare.out, save-door.log, spec-files.log). Worktree removed afterwards.

  • ①1 Accept-set census, before vs after — RIGHT. 76 + 18 bodies, every body at both trees.

    • REFUSE→ACCEPT (7): W1.layout (layout: 'diagonal'), W1.sections.bad (sections: 'nope'), W1.title.bad (title: 42) — W1 exactly (invalid value under a form-only key, dropped unread, parse output {name,type:'grid',object,viewKind}); W2.aria, W2.description.i18n, W2.sharing.list ({type:'personal'}), W2.options.map — W2 exactly (list-legal value under a divergent shared key, kept in the parse). Each was refused on main by the list member at columns and never accepted by the form member. No other widening found: every other REFUSE stays REFUSE (overlay.list.identity, grid without columns, calendar without columns, type: null, columns on a form with type: 'grid', absent-viewKind bodies, identity-only bodies).
    • ACCEPT→REFUSE (11 + 8 edge): retired sort string (row 1), direction alias (row 2), timeline.metaFields (row 3), string searchableFields (row 4), form-style sharing (row 5), mirror viewKind: 'form' + columns: ['name'] (row 7); invalid values under list keys on a column-less, type-less list body — hiddenFields: 'email', inlineEdit: 'yes', rowHeight: 'gigantic', bad emptyState key, userFilters tab shape, retired filter triple, console operator: '=', a kanban block missing its own required keys, appearance.allowedVisualizations: ['calendar'] without a calendar block — all row 6 (「any other list key the list view schema refuses … the refusal names the key」, and each refusal does name the key). One class the table does not spell out but row 6 covers: a form body mislabelled viewKind: 'list' (type: 'simple' + sections), accepted on main as a form, refused now at type (invalid_value, the list enum) with no 「set viewKind: "form"」 hint. No real producer writes it (objectui stamps viewKind from the tab). Not blocking.
    • Same verdict, changed parse output (declared): every column-less list patch parses to type: 'grid' on listOverlay with its list keys kept, instead of type: 'simple' on formOverlay with them stripped. Controls byte-identical: real form overlay, form with groups, list with columns, authored type: 'kanban'/'calendar'/'grid' with columns, the objectui saved-view fat body.
  • ①2 Producer safety (updateViewConfig stores "per-view personal config" as ONE org-wide metadata item — sort/hiddenFields/columnState/rowHeight written by any user apply to every user of the view #7494) — RIGHT. Read at the pin: buildPersistedViewBody (ObjectView.tsx:1065) returns { ...patch, viewKind } for an overlay; updateViewConfig (data-objectstack/src/index.ts:5318) stamps object, name, _isOverride; call sites :2620 (rowHeight), :2625/:2923 (sort), :2631/:2939 (hiddenFields), :2634/:2945 (columnState), :2942 (inlineEdit). Probed one body per path plus variants and a batched one: ACCEPT on listOverlay, output type: 'grid', at the head; through the real saveMetaItem at the head each is success: true and the row is the body verbatim (_isOverride included). A patch with no viewKind and a shadowed registry entry inherits viewKind: 'list' (normalizeViewMetadata, unchanged) and saves; without a baseline it is refused on both trees (unchanged). No producer write is refused.

  • ①3 What reaches storage — RIGHT, and named by the PR. saveMetaItem (protocol.ts:16154–16208) persists request.item after graftNormalizedOperators(graftFoldedFormSections(item, parsed.data), parsed.data); withDeclaredPageTypeDefault is page-only (protocol.ts:1367), so no type is ever grafted into a view row. Measured: (a) real save at the head — filter[].operator: 'eq' on a column-less list patch is stored as 'equals'; groups on a column-less list patch is stored as groups; W1 layout: 'diagonal' stored as sent; (b) the same bodies on main through the verbatim graft functions (extracted from protocol.ts at the head, grafts.ts): eq stored as eq (form member stripped filter, so the walk found no counterpart), groups stored as sections (form fold). Readers: objectui's overlay merge narrows an _isOverride row to identity + rowHeight/sort/hiddenFields/columnState/inlineEdit (narrowPersonalizationOverlay, index.ts:2901), dropping filter/groups/sections; listViewOverrides/getView hand the document through unchanged; the rest switcher (rest-server.ts:7001) and getViewsByObject (metadata-manager.ts:1727) filter on viewKind + object and sort on order; the /forms/:slug reader takes viewKind === 'form' rows only. eq→equals is the canonical fold every reader already applies (normalizeFilterOperator; it already happened on every list overlay WITH columns, measured on main), and no reader keyed on the alias was found (objectui's literal case 'eq' sites are context-selector / data-scope / record-picker vocabularies, not stored view rows). No reader answers differently for a producer-written row: those rows are byte-identical before and after. Not blocking.

  • ①4 Input-side refusal and .overwrite() — RIGHT. checkListOverlayTypeNeedsColumns reads view.type on a member whose type carries no default (listOverlayPatchFields), so it sees the input; _zod.def.type is 'object' for both overlay members at the head (no pipe). Served JSON Schema, both directions, main→head, deep-diffed: anyOf length 4 at both; members 0 and 1 byte-identical; member 2: viewKind.enum ["list","form"]→["list"], required ["columns","object","viewKind"]→["object","viewKind"] (input) and ["type","columns","object","viewKind"]→["object","viewKind"] (output), properties.type (incl. default: 'grid') byte-identical; member 3: viewKind.enum→["form"], nothing else. Exactly the PR's statement. .overwrite() puts type: 'grid' only when type is absent — authored kanban, calendar, grid with columns come out byte-identical to main; a column-less patch gets grid in the shape's key position ({"name","type":"grid",…} matches main's default placement on the with-columns control). overlay.list.identity (kanban, no columns): REFUSE on both, top-level code invalid_union on both; the diagnosis moves from a bare invalid_union at columns to custom at columns with the prescription. Note (non-blocking, declared): the served input schema of member 2 is now wider than the parse on the type-bearing column-less class (the check is unrepresentable), and the output schema no longer lists type as required although the overwrite always supplies it; protocol.ts:501/521 serve both directions.

  • ①5 Error UX — RIGHT. At the head, every refused class is located under the arm the body claims: retired sort string → listOverlay, invalid_type at sort, message begins 「The bare string sort clause was removed from view.sort in @objectstack/spec 17.5.0」 (true: that retirement's own text); timeline.metaFields → unrecognized_keys at timeline naming metaFields; string searchableFields → invalid_type at searchableFields; form sharing → unrecognized_keys at sharing; type-bearing column-less → custom at columns, text names both ways out (add columns, or drop type), and both are true (kanban + columns is ACCEPT; the same body without type is ACCEPT as a patch); mirror → formOverlay, invalid_type at columns, 「columns is the NUMBER of body columns (an integer, 1 or more)」 — true against FormViewSchema.columns = z.number().int().min(1).optional() (view.zod.ts:4086; columns: 2 accepted, columns: 0 too_small). The real save door surfaces the same issues in the 422 issues[] (path: 'sort' etc.) and in the message. The union's own top-level message stays Invalid input for these, as it already does for a with-columns list overlay on main (retirementPrescription lifts only expected: 'never' tombstones); consistent, not a regression. overlayViewKindArmMismatch appears on a direct member parse only, as stated.

  • ①6 Pins — RIGHT. view-overlay-viewkind-arm.test.ts asserts branch, top-level codes, issue code + path + prescription text, output type, byte-identical controls, member def types and both JSON-schema directions. protocol.graft-folded-form-sections.test.ts (+97) asserts INVALID_METADATA, 422, zero rows, issue path/code/message per refusal, and row-equals-body per acceptance (the row holds the verbatim body, so an output type assertion is not applicable there). Both files run green here (spec: 6 files / 212 tests incl. view-union-diagnostics, view-overlay-options-bag, view-union-retirement-prescription, view-metadata-schema, view-spelling-walk; metadata-protocol: 2 files / 32 tests incl. my probe). The only flipped existing pins are the three put.* output rows (simple→grid), named in the PR; overlay.list.identity is unchanged in the REFUSED table; no existing pin asserts a W1/W2 body refused (swept the 4 test files that name formOverlay beside a viewKind: 'list' body). Gap, non-blocking: the direction alias row of the FROM→TO table has no pin at either door (measured here: ACCEPT on main, REFUSE at sort.0 at the head).

② Semver level

  • '@objectstack/spec': minor with **BREAKING**, fix(spec)!: and Clause-②: yes (narrowing): the launch-window guard forbids major (check-changeset-no-major.mjs, 「During the launch window we ship breaking changes as minor」), and the LEVEL axis needs a declared clause ② to grade at least one moved published package minor or above — spec is. CI job Check Changeset at the head: steps 13 (ADR-0087 disposition) and 15 (no-major) both success.
  • yes (narrowing) is the ruled spelling for this diff: scripts/pm/clause2-line.mjs:93 — 「yes (narrowing) — a diff that widens one surface and narrows another. Both facts are true and both are read.」 breakingDeclaration (check-adr-0087-registration.mjs:629) reads signals BREAKING, bang and clause-②-narrowing, so a disposition is owed and given.
  • FROM→TO table: complete against the census in ①1 (rows 1–5 and 7 are the named classes; row 6 is the generic list-key row that covers every other measured narrowing, each refusal naming its key). Widenings W1 and W2 are declared with one measured body each and match the census exactly.
  • Disposition registered view-overlay-judged-by-viewkind-arm is correct: the semantic entry 18.view-overlay-judged-by-viewkind-arm.ts is new in this diff and the regenerated registry carries it once; semantic (not a conversion) is right, since which value a refused key meant is the author's. With main moved to fdb26698 (three commits past the merge base, one of them ca753c01 adding its own step18 entry) git merge-tree is clean; the merged registry holds both new ids once and its 188 step18 ids stay in alphabetical order.

③ Boundary flags

Implemented-by: claude/issue-20186-view-overlay-viewkind-arm
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nd the 25 missing entries (objectstack-ai#20201) (objectstack-ai#20255)

Fixes objectstack-ai#20201
Clause-②: no

## What

Ruling B on objectstack-ai#17152 (director `5615360777`, restated `5634031140`, on the
maintainer's objectstack-ai#15954 authority `5559778263`): every retirement family
carries ONE ADR-0087 D3 (`semantic`) entry, even when a lossless D2
conversion repairs its data; D2 carries the mechanical repair only. This
PR takes the major-18 family census the card asks for, adds the 25 D3
entries it found missing, corrects the prose that justified their
absence, and pins the census in the registry's own test.

- **25 new D3 entries** under
`packages/spec/src/migrations/entries/semantic/18.*.ts`, one per
D2-backed family that had none. Each names its family and its D2
conversion id, says what D2 already repairs, and says what judgment the
consumer still owes (the `reason`), with an `acceptanceCriteria` the
consumer can check. None is a placeholder: every one states a residue
specific to its family (a unit only the author knows, a belief the
platform never honoured, a shape the conversion deliberately leaves
alone, code the chain cannot reach).
- **Prose corrected** at the sites of `5854110917` and `5854456343`,
plus step 18's rationale and step 17's docblock fact (list below).
- **Census pin** in the existing
`packages/spec/src/migrations/migrations.test.ts` (registry integrity).
No new check script.
- `MIGRATIONS_BY_MAJOR[18].semantic` 186 → 211 entries at the census
base; after merging `main` (four sibling D3 entries landed meanwhile,
none with a D2 conversion) the generated region holds 215.

## The census (the card's main deliverable)

**Tree.** `objectstack-ai/objectstack` at `3cb84d084` (this branch's
fork point; it already contains objectstack-ai#20227's view-item retirement). Major-18
population there: **185** `retired-keys`, **146** `retired-defs`,
**186** `semantic` entry files, and **36** D2 conversions graduated into
step 18. (The card measured 181 / 130 / 175 at `d7c024133e`.)

**Grouping rule (ruling B's unit, held constant).** Where a D2
conversion exists, the conversion is the family: every retired key or
def it repairs belongs to it, and a D3 entry may cover more than one
conversion only where one judgment covers them (the pre-existing
`element-filter-and-form-node-refused` covers `element-filter-removed`
and `element-form-removed`). Every new entry here covers exactly one
conversion. Where no conversion exists, the records are grouped by the
D3 entry that names them.

**Method.**
1. Mechanical pass (scratch scripts, not committed): for each of the 36
conversions, the major-18 `semantic/` entry files that name its id as a
whole id (comment or field); for each retired key, the conversion its
own comment names, else the major-18 entries naming it as `cat/Def:key`,
`Def.path` / `Def:path`, or the def name plus the leaf key; for each
retired def, the entries naming the def.
2. Reading pass, where a string match cannot decide: (a) ownership: an
entry that names a conversion only in passing is not that family's entry
(this is how `metric-filters-removed` was classed missing although
`analytics-authorable-unknown-keys-refused` names it); (b) 14 records
matched by more than one entry, each placed by its own comment (for
example `kernel/PluginStartupResult:plugin`, which goes to
`startup-orchestrator-retired`); (c) 9 records whose comment names no
conversion but which belong to a D2 family
(`integration/DeclarativeConnectorEntry:connectionTimeoutMs` and
`:errorMapping`, the three error-mapping defs, the four responsive-shape
defs), plus `integration/Connector:connectionTimeoutMs`, whose comment
names two conversion ids and belongs to
`connector-connection-timeout-ms-removed` (it names the permission
conversion only as a comparison; round 1's Table 1 placed it wrongly,
corrected in patch round 1); (d) 5 theme sub-block defs, named by the
theme family's entry as its sub-blocks.

**Control.** The pairing sees a family that has its entry: 11 of the 36
conversions pair with a pre-existing entry, among them
`cube-join-sql-and-relationship-removed` with
`cube-join-sql-and-relationship-retired` (which the pin's own control
test also asserts), and the whole-id matcher refuses a prefix
(`record-chatter-position-vocabulary` is a prefix of its entry's own id
and matches only the entry's real citation). Evaluated at the base with
the pin's logic: **24** conversions named by no major-18 entry; the
reading pass adds `metric-filters-removed` for **25**. Evaluated at this
head: **0**.

**Result.** 331 records (185 keys + 146 defs) plus 36 conversions:

- **36 D2-backed families** covering 58 records: 11 had their D3 entry,
**25 had none**. Table 1.
- **273 D2-less records** in 68 groups: every one is named by an
existing D3 entry. Table 2. None missing, as expected: before ruling B,
a retirement with no conversion needed a D3 entry anyway.

The card's grep for 「lossless」 found the `tenancy.organizationField`
site and step 17. The census finds 25 major-18 families, most of them
with no 「lossless」 wording at all.

### Table 1 — D2-backed families (step 18 `conversionIds`, in order)

| # | D2 conversion (the family) | registered records | D3 entry at base
| D3 entry after |
|---|---|---|---|---|
| 1 | `field-malformed-scale-precision-removed` | none (value or
strict-key retirement, not in the two tables) |
`field-scale-precision-integer-refused` | unchanged |
| 2 | `record-chatter-position-vocabulary` | none (value or strict-key
retirement, not in the two tables) |
`record-chatter-position-vocabulary-converged` | unchanged |
| 3 | `element-input-target-variable-removed` |
`ui/ElementRecordPickerProps:targetVariable`,
`ui/ElementTextInputProps:targetVariable` | MISSING |
`element-input-target-variable-retired` (new) |
| 4 | `element-filter-removed` | `ui/ElementFilterProps:aria`,
`ui/ElementFilterProps:fields`, `ui/ElementFilterProps:layout`,
`ui/ElementFilterProps:object`, `ui/ElementFilterProps:showSearch`,
`ui/ElementFilterProps:targetVariable` |
`element-filter-and-form-node-refused` | unchanged |
| 5 | `element-form-removed` | `ui/ElementFormProps:aria`,
`ui/ElementFormProps:fields`, `ui/ElementFormProps:mode`,
`ui/ElementFormProps:object`, `ui/ElementFormProps:onSubmit`,
`ui/ElementFormProps:submitLabel` |
`element-filter-and-form-node-refused` | unchanged |
| 6 | `field-column-lists-canonicalized` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`field-inline-and-related-list-columns-closed` (new) |
| 7 | `metric-filters-removed` | `data/Metric:filters` | MISSING (named
only in passing by `analytics-authorable-unknown-keys-refused`) |
`cube-metric-filters-retired` (new) |
| 8 | `cube-sub-day-granularities-removed` | none (value or strict-key
retirement, not in the two tables) |
`time-update-interval-sub-day-retired` | unchanged |
| 9 | `cube-join-sql-and-relationship-removed` |
`data/CubeJoin:relationship`, `data/CubeJoin:sql` |
`cube-join-sql-and-relationship-retired` | unchanged |
| 10 | `record-highlights-field-icon-removed` |
`ui/RecordHighlightsField:icon` | MISSING |
`record-highlights-field-icon-retired` (new) |
| 11 | `mapping-lookup-params-removed` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`mapping-lookup-params-retired` (new) |
| 12 | `translation-component-submit-label-removed` | none (value or
strict-key retirement, not in the two tables) | MISSING |
`translation-component-submit-label-retired` (new) |
| 13 | `page-component-responsive-removed` |
`ui/PageComponent:responsive`, `ui/BreakpointColumnMap`,
`ui/BreakpointName`, `ui/BreakpointOrderMap`, `ui/ResponsiveConfig` |
MISSING | `page-component-responsive-retired` (new) |
| 14 | `object-grid-default-sort-removed` |
`ui/ObjectGridProps:defaultSort` | MISSING |
`object-grid-default-sort-retired` (new) |
| 15 | `object-kanban-quick-add-removed` |
`ui/ObjectKanbanProps:quickAdd` | MISSING |
`object-kanban-quick-add-retired` (new) |
| 16 | `permission-allow-restore-purge-removed` |
`security/EffectiveObjectPermission:allowPurge`,
`security/EffectiveObjectPermission:allowRestore`,
`security/ObjectPermission:allowPurge`,
`security/ObjectPermission:allowRestore` | MISSING |
`permission-restore-purge-bits-retired` (new) |
| 17 | `form-view-option-default-removed` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`form-view-option-default-retired` (new) |
| 18 | `field-reference-to-alias` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`field-reference-to-spelling-retired` (new) |
| 19 | `connector-error-mapping-removed` |
`integration/Connector:errorMapping`,
`integration/DeclarativeConnectorEntry:errorMapping`,
`integration/ConnectorErrorCategory`, `integration/ErrorMappingConfig`,
`integration/ErrorMappingRule` | MISSING |
`connector-error-mapping-retired` (new) |
| 20 | `connector-connection-timeout-ms-removed` |
`integration/Connector:connectionTimeoutMs`,
`integration/DeclarativeConnectorEntry:connectionTimeoutMs` |
`connector-provider-context-connection-timeout-ms-retired` | unchanged |
| 21 | `hook-timeout-to-timeout-ms` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`hook-timeout-unit-in-key` (new) |
| 22 | `job-timeout-to-timeout-ms` | `system/Job:timeout` | MISSING |
`job-timeout-unit-in-key` (new) |
| 23 | `api-endpoint-cache-ttl-to-cache-ttl-seconds` |
`api/ApiEndpoint:cacheTtl` | MISSING |
`api-endpoint-cache-ttl-unit-in-key` (new) |
| 24 | `dashboard-refresh-interval-to-refresh-interval-seconds` |
`ui/Dashboard:refreshInterval` | MISSING |
`dashboard-refresh-interval-unit-in-key` (new) |
| 25 | `connector-health-and-trigger-durations-unit-in-key` |
`integration/CircuitBreakerConfig:monitoringWindow`,
`integration/ConnectorTrigger:interval` | MISSING |
`connector-resilience-durations-unit-in-key` (new) |
| 26 | `memory-persistence-auto-save-interval-to-ms` |
`data/AutoPersistenceConfig:autoSaveInterval`,
`data/FilePersistenceConfig:autoSaveInterval` | MISSING |
`memory-persistence-auto-save-interval-unit-in-key` (new) |
| 27 | `turso-config-timeout-to-timeout-ms` | `data/TursoConfig:timeout`
| MISSING | `turso-config-timeout-unit-in-key` (new) |
| 28 | `view-page-mount-removed` | `ui/ListView:pageName`,
`ui/ObjectListView:pageName` | MISSING | `list-view-page-mount-retired`
(new) |
| 29 | `list-view-sort-string-clause-to-array` | none (value or
strict-key retirement, not in the two tables) | MISSING |
`list-view-sort-string-clause-retired` (new) |
| 30 | `page-assigned-profiles-removed` | `ui/Page:assignedProfiles` |
`page-assigned-profiles-audience-to-permission-set` | unchanged |
| 31 | `chart-config-aria-removed` | `ui/ChartConfig:aria`,
`ui/ReportChart:aria` | MISSING | `chart-config-aria-retired` (new) |
| 32 | `dashboard-widget-chart-config-structure-removed` |
`ui/DashboardWidgetChartConfig:series`,
`ui/DashboardWidgetChartConfig:type`,
`ui/DashboardWidgetChartConfig:xAxis`,
`ui/DashboardWidgetChartConfig:yAxis` |
`dashboard-widget-chart-config-structure-refused` | unchanged |
| 33 | `translation-per-app-settings-removed` | none (value or
strict-key retirement, not in the two tables) |
`translation-per-app-settings-platform-only` | unchanged |
| 34 | `object-tenancy-organization-field-removed` |
`data/TenancyConfig:organizationField` | MISSING |
`object-tenancy-organization-field-retired` (new) |
| 35 | `page-component-filter-record-to-rule-array` | none (value or
strict-key retirement, not in the two tables) |
`element-data-source-and-object-block-filter-rule-array`,
`object-grid-default-filters-rule-array` | unchanged |
| 36 | `view-item-owner-hidden-removed` | `ui/ViewItemWire:hidden`,
`ui/ViewItemWire:owner`, `ui/ViewItem:hidden`, `ui/ViewItem:owner` |
MISSING | `view-item-owner-hidden-retired` (new) |

### Table 2 — D2-less records, grouped by the existing D3 entry that
names them

| D3 entry (existing) | records it names |
|---|---|
| `advanced-plugin-lifecycle-config-retired` |
`kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`,
`kernel/PluginUpdateStrategy` |
| `ai-conversation-analytics-duration-unit-in-key` |
`ai/ConversationAnalytics:duration` |
| `api-error-retry-after-unit-in-key` |
`api/EnhancedApiError:retryAfter` |
| `api-runtime-config-durations-unit-in-key` |
`api/DataLoaderConfig:cacheTtl`, `api/RouteDefinition:timeout` |
| `automation-flow-list-route-retired` | `api/FlowSummary`,
`api/ListFlowsRequest`, `api/ListFlowsResponse` |
| `automation-runs-cursor-retired` | `api/ListRunsRequest:cursor` |
| `branded-identifier-schemas-retired` | `shared/AppName`,
`shared/FieldName`, `shared/FlowName`, `shared/ObjectName`,
`shared/RoleName`, `shared/ViewName` |
| `change-management-duration-keys-retired` |
`system/ChangeImpact:downtime.durationMinutes`,
`system/ChangeRequest:implementation.steps.estimatedMinutes`,
`system/RollbackPlan:steps.estimatedMinutes` |
| `change-management-family-retired` | `system/ChangeImpact`,
`system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`,
`system/ChangeType`, `system/RollbackPlan` |
| `cli-command-contribution-retired` | `kernel/CLICommandContribution` |
| `cloud-subpath-retired` | 62 records, all `cloud/` defs |
| `data-file-value-duration-unit-in-key` | `data/FileValue:duration` |
| `data-nosql-query-options-timeout-unit-in-key` |
`data/NoSQLQueryOptions:timeout` |
| `device-request-response-interval-unit-in-key` |
`api/DeviceRequestResponse:interval` |
| `driver-options-timeout-to-timeout-ms` | `data/DriverOptions:timeout`
|
| `epoch-instant-keys-renamed` | `api/SimplePresenceState:lastSeen`,
`api/WebSocketEvent:timestamp`, `kernel/HealthStatus:timestamp`,
`kernel/KernelContext:startTime`,
`kernel/TenantRuntimeContext:startTime` |
| `esignature-config-deadline-keys-retired` |
`data/ESignatureConfig:expirationDays`,
`data/ESignatureConfig:reminderDays` |
| `event-name-schema-retired` | `shared/EventName` |
| `export-job-family-retired` | 13 records, all `api/`, `automation/`
defs |
| `hot-reload-inert-state-strategies-retired` |
`kernel/DistributedStateConfig` |
| `hot-reload-watch-placeholder-retired` |
`kernel/HotReloadConfig:watchPatterns` |
| `identity-api-key-schema-retired` | `identity/ApiKey` |
| `incident-response-deadline-keys-retired` |
`system/IncidentNotificationMatrix:escalationTimeoutMinutes`,
`system/IncidentNotificationRule:regulatorDeadlineHours`,
`system/IncidentNotificationRule:withinMinutes`,
`system/IncidentResponsePhase:targetHours`,
`system/IncidentResponsePolicy:retentionDays`,
`system/IncidentResponsePolicy:triageDeadlineHours` |
| `incident-response-family-retired` | `system/Incident`,
`system/IncidentCategory`, `system/IncidentNotificationMatrix`,
`system/IncidentNotificationRule`, `system/IncidentResponsePhase`,
`system/IncidentResponsePolicy`, `system/IncidentSeverity`,
`system/IncidentStatus` |
| `kernel-compatibility-matrix-estimated-migration-time-unit-in-key` |
`kernel/CompatibilityMatrixEntry:estimatedMigrationTime` |
| `kernel-context-preview-mode-retired` |
`kernel/KernelContext:previewMode`, `kernel/PreviewModeConfig`,
`kernel/TenantRuntimeContext:previewMode` |
| `kernel-event-bus-retention-unit-in-key` |
`kernel/EventPersistence:retention`,
`kernel/EventSourcingConfig:retention` |
| `kernel-health-check-and-hot-reload-durations-unit-in-key` |
`kernel/HotReloadConfig:debounceDelay`,
`kernel/PluginHealthCheck:interval`, `kernel/PluginHealthCheck:timeout`
|
| `kernel-package-lifecycle-durations-unit-in-key` |
`kernel/MultiVersionSupport:rollout.duration`,
`kernel/PackageDependencyResolutionResult:resolvedIn`,
`kernel/UpgradePlan:estimatedDuration` |
| `kernel-plugin-health-report-durations-unit-in-key` |
`kernel/PluginHealthReport:metrics.responseTime`,
`kernel/PluginHealthReport:metrics.uptime` |
| `kernel-plugin-security-durations-unit-in-key` |
`kernel/KernelSecurityPolicy:auditLog.retention`,
`kernel/KernelSecurityPolicy:authentication.tokenExpiration`,
`kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime` |
| `kernel-runtime-config-timeout-unit-in-key` |
`kernel/RuntimeConfig:resourceLimits.timeout`,
`kernel/SandboxConfig:process.timeout` |
| `kernel-startup-orchestrator-durations-unit-in-key` |
`kernel/PluginStartupResult:duration`, `kernel/StartupOptions:timeout`,
`kernel/StartupOrchestrationResult:totalDuration` |
| `list-view-navigation-view-retired` | `ui/NavigationConfig:view` |
| `logging-durations-unit-in-key` |
`system/HttpDestinationConfig:batch.flushInterval`,
`system/HttpDestinationConfig:retry.initialDelay`,
`system/HttpDestinationConfig:timeout`,
`system/LoggingConfig:buffer.flushInterval` |
| `metadata-changed-event-payload-retired` |
`kernel/MetadataChangeOperation`, `kernel/MetadataChangedEventPayload` |
| `metadata-customization-protocol-retired` | 13 records, all `api/`,
`kernel/` defs |
| `metadata-manager-config-cache-ttl-unit-in-key` |
`kernel/MetadataManagerConfig:cache.ttl` |
| `metadata-manager-config-inert-cache-keys-retired` |
`kernel/MetadataManagerConfig:cache.enabled`,
`kernel/MetadataManagerConfig:cache.maxSize`,
`kernel/MetadataManagerConfig:cache.ttlSeconds` |
| `metadata-plugin-additional-types-retired` |
`kernel/MetadataPluginConfig:additionalTypes` |
| `package-rollback-response-retired` | `api/PackageRollbackResponse` |
| `packages-list-pagination-retired` |
`api/ListInstalledPackagesRequest:cursor`,
`api/ListInstalledPackagesRequest:limit` |
| `plugin-auto-restart-never-reinitialised` |
`kernel/PluginHealthCheck:autoRestart`,
`kernel/PluginHealthCheck:maxRestartAttempts`,
`kernel/PluginHealthCheck:restartBackoff` |
| `plugin-manifest-contributes-dead-members-retired` |
`kernel/Manifest:contributes.actions`,
`kernel/Manifest:contributes.commands`,
`kernel/Manifest:contributes.drivers`,
`kernel/Manifest:contributes.events`,
`kernel/Manifest:contributes.fieldTypes`,
`kernel/Manifest:contributes.functions`,
`kernel/Manifest:contributes.menus`,
`kernel/Manifest:contributes.themes`,
`kernel/Manifest:contributes.translations` |
| `plugin-manifest-contributes-routes-retired` |
`kernel/Manifest:contributes.routes` |
| `plugin-manifest-dead-containers-retired` |
`kernel/Manifest:capabilities`, `kernel/Manifest:configuration`,
`kernel/Manifest:extensions` |
| `plugin-manifest-kind-globs-retired` |
`kernel/Manifest:contributes.kinds.globs` |
| `plugin-security-scan-result-surface-retired` |
`kernel/KernelSecurityScanResult`, `kernel/KernelSecurityVulnerability`,
`kernel/PluginQualityMetrics:securityScan`,
`kernel/PluginSecurityManifest:scanResults`,
`kernel/PluginSecurityManifest:vulnerabilities` |
| `rest-api-endpoint-handler-status-retired` | `api/HandlerStatus`,
`api/RestApiEndpoint:handlerStatus`, `api/RouteCoverageEntry`,
`api/RouteCoverageReport` |
| `rest-api-plugin-durations-unit-in-key` |
`api/RestApiEndpoint:cacheTtl`, `api/RestApiEndpoint:timeout`,
`api/RestApiPluginConfig:performance.defaultCacheTtl` |
| `rest-server-config-dead-keys-retired` | 11 records, all `api/` defs |
| `session-user-language-retired` | `api/SessionUser:language` |
| `stack-themes-carrier-retired` | `ui/BorderRadius`, `ui/ColorPalette`,
`ui/Shadow`, `ui/Theme`, `ui/ThemeMode`, `ui/Typography` |
| `startup-orchestrator-retired` | `kernel/HealthStatus`,
`kernel/PluginStartupResult:health`,
`kernel/PluginStartupResult:plugin`,
`kernel/PluginStartupResult:startTime`, `kernel/StartupOptions`,
`kernel/StartupOrchestrationResult` |
| `system-cache-durations-unit-in-key` |
`system/CacheAvalanchePrevention:circuitBreaker.resetTimeout`,
`system/CacheTier:ttl` |
| `system-collaboration-durations-unit-in-key` |
`system/CollaborationSessionConfig:idleTimeout`,
`system/CollaborationSessionConfig:snapshot.interval` |
| `system-failover-health-check-interval-unit-in-key` |
`system/FailoverConfig:healthCheckInterval` |
| `system-metrics-jsdoc-durations-unit-in-key` |
`system/MetricDefinition:summary.maxAge`,
`system/MetricExportConfig:interval`,
`system/MetricsConfig:collectionInterval`,
`system/MetricsConfig:retention.period`,
`system/ServiceLevelObjective:errorBudget.burnRateWindows.window` |
| `system-metrics-window-durations-unit-in-key` |
`system/MetricAggregationConfig:window.size`,
`system/ServiceLevelIndicator:window.size`,
`system/ServiceLevelObjective:period.duration` |
| `system-object-storage-durations-unit-in-key` |
`system/AccessControlConfig:maxAge`, `system/StorageConnection:timeout`
|
| `system-registry-config-durations-unit-in-key` |
`system/RegistryConfig:cache.ttl`,
`system/RegistryUpstream:syncInterval`,
`system/RegistryUpstream:timeout` |
| `system-tracing-otel-exporter-durations-unit-in-key` |
`system/OpenTelemetryCompatibility:exporter.batch.exportTimeout`,
`system/OpenTelemetryCompatibility:exporter.batch.scheduledDelay`,
`system/OpenTelemetryCompatibility:exporter.timeout`,
`system/TracingConfig:performance.exportInterval` |
| `system-tracing-span-duration-unit-in-key` | `system/Span:duration` |
| `system-worker-queue-rate-limit-duration-unit-in-key` |
`system/QueueConfig:rateLimit.duration` |
| `tenant-schema-cache-ttl-unit-in-key` |
`system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL` |
| `training-deadline-keys-retired` |
`system/TrainingCourse:durationMinutes`,
`system/TrainingCourse:validityDays`,
`system/TrainingPlan:gracePeriodDays`,
`system/TrainingPlan:recertificationIntervalDays`,
`system/TrainingPlan:reminderDaysBefore` |
| `training-family-retired` | `system/TrainingCategory`,
`system/TrainingCompletionStatus`, `system/TrainingCourse`,
`system/TrainingPlan`, `system/TrainingRecord` |
| `websocket-durations-unit-in-key` |
`api/WebSocketConfig:pingInterval`,
`api/WebSocketConfig:reconnectInterval`, `api/WebSocketConfig:timeout`,
`api/WebSocketServerConfig:heartbeatInterval` |

## Prose corrected (the single-entry sites of `5854110917` /
`5854456343`, and the rationale sentences)

| site (at this head) | was | now |
|---|---|---|
| `packages/spec/src/migrations/registry.ts:78–86` (step 17 docblock,
fact correction only) | 「Mechanical, and mechanical only … there is no
semantic residue and the `semantic` list is deliberately empty」 | the
three renames replay losslessly as D2; they carry no D3 entry because
step 17 shipped before the rule and was not back-filled; the `semantic`
list is NOT empty. ⛔ No step-17 entry added. |
| `packages/spec/src/migrations/registry.ts:5256` (step 18 rationale,
`tenancy.organizationField`) | 「The conversion is a lossless delete and
there is no semantic residue」 | a lossless delete still leaves the
author a judgment, carried by
`object-tenancy-organization-field-retired` |
| `packages/spec/src/conversions/registry.ts:3460`
(`datasource-driver-mongo-to-mongodb`, protocol 17) | 「Why D2 and not
D3」 | 「Why the data repair is D2」, plus: losslessness does not decide
whether a family owes D3; this one is protocol 17 and has none |
| `packages/spec/src/conversions/registry.ts:9312`
(`api-endpoint-cache-ttl-to-cache-ttl-seconds`) | 「gets a conversion
rather than a semantic entry」 | 「also gets a conversion」, and names its
D3 entry |
| `packages/spec/src/conversions/registry.ts:9804`
(`list-view-sort-string-clause-to-array`) | 「which is why this is a D2
conversion rather than a semantic TODO」 | the data repair is D2; the
family's D3 entry carries the clauses the rewrite leaves alone |
|
`packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts:11–19`
| 「a D2 CONVERSION rather than a semantic entry」 | also a D2 conversion,
and names the D3 entry |
|
`packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts:11–13`
| 「exactly the residue D2 cannot express, which is why this is a
semantic entry」 | that residue is why there is no D2 at all; the D3
entry is owed either way |
| `packages/spec/scripts/build-migration-registry.ts:276` | 「a major
whose semantic residue is genuinely nil (protocol 14)」 | an empty region
is a real state (a freshly opened step, or protocol 14's, which predated
the rule) |

⛔ Not touched: the governed texts (ADR-0087,
`.claude/skills/spec-property-retirement/SKILL.md` §3), which are
objectstack-ai#20188's.

## The census pin

**Where:** `packages/spec/src/migrations/migrations.test.ts` › `registry
integrity`: `from protocol 18 on, every graduated D2 conversion is named
by a D3 entry of its own step (ruling B)`, plus a control test. It reads
the major's `entries/semantic/` files (comment and literal) inside its
own package; `check:migration-registry` already proves those files and
the generated region are one set.

**What it asserts:** for every step whose `toMajor` is 18 or later,
every id in `conversionIds` appears as a whole id in at least one
`semantic/` entry of that major. A new major-18 (or later) retirement
that lands a D2 conversion with no D3 entry naming it goes red, naming
the conversion.

**What it cannot see**, stated so a green run is not over-read: (1)
whether the naming entry is that family's OWN (a passing mention
satisfies it; the census judged ownership by reading); (2) a family
retired with no conversion at all (no machine-readable link joins a
retired key or def to its D3 entry; the census paired those by reading,
and found none missing). Protocol 17 is outside the pin by design:
measured with the same logic, 53 of its 57 graduated conversions are
named by no step-17 entry, and step-17 backfill is out of scope (triage
`5854164872`).

**Reverse verification (one-shot, no permanent test file).** At
`c8656ad35`, with the entries committed: deleted
`18.object-tenancy-organization-field-retired.ts` (absence confirmed on
disk before the run), ran the pin: `× from protocol 18 on …` with `+
"protocol 18: object-tenancy-organization-field-removed"`, `Tests 1
failed | 140 skipped`. Restored with `git checkout HEAD -- PATH` (that
path) inside a `trap … EXIT INT TERM`: blob `2cf3007d9fff` equals
HEAD's, `git diff HEAD` empty. Direction observed: red, the expected
one. No build involved: the test imports `src/` and reads the entry
files directly.

## Patch round 1 (contract review `5857834457`: FAIL at `3197fce29`)

**Blocking: fixed.** `Lint & Repo Gates` step 189
(`check-issue-citations.mjs`, judging pass) was red. Four bare citations
this PR added answer 404 on the board: objectstack-ai#10329, objectstack-ai#10926, objectstack-ai#12868 and
objectstack-ai#14676. Each was in an entry's leading comment, and again in the
regenerated region. `--probe-cause` classes all four as **deleted** (the
web endpoint also answers 404, so none was transferred), so none of them
is a reference to another repository to qualify. Each comment now
anchors to the commit in this repository's history that retired the
family, and says in words what that commit decided. That is the
precedent of commit `66e266c93` (ruling C+D on objectstack-ai#19123). Every sha is an
ancestor of `origin/main`:

| entry | was | now anchored to |
|---|---|---|
| `mapping-lookup-params-retired` | objectstack-ai#10329 | commit `15d58dbf1` (the
import path never read the four lookup steering params) |
| `translation-component-submit-label-retired` | objectstack-ai#10926 | commit
`d173125fb` (the copy key left with its only declarer, `element:form`) |
| `form-view-option-default-retired` | objectstack-ai#12868 | commit `c459da6bc` (the
ruled narrowing: the form-view face drops per-option `default`, the
object-field face keeps it enforced) |
| `connector-error-mapping-retired` | objectstack-ai#14676 | commit `13c48c2a5`
(eleven inert keys, one spelled like the live `userMessage` channel) |

Only the comments changed; no string an author is shown moves. The
region was regenerated with `gen:migration-registry`. The round-1
report's `pnpm check:issue-citations :: exit 0` was the package script,
which runs only the `--self-test`. The judging pass CI runs was exit 2
at `3197fce29` (8 findings = 4 numbers × 2 sites) and is exit 0 now
(below).

**Pin message.** The census pin's assertion now names the unnamed
`protocol N: conversion-id` pairs and the remedy: add a D3 `semantic`
entry of that step whose text names the conversion id as a whole word.
Its logic and scope are unchanged. Shown firing at `21418c4d2` with one
entry removed (trap-guarded restore, blob equal to HEAD's, `git diff
HEAD` empty): `AssertionError: graduated D2 conversion(s) named by no D3
entry of their own step: protocol 18:
object-tenancy-organization-field-removed. Remedy: add a D3 semantic
entry of that step …`, `Tests 1 failed | 140 passed`.

**Body.** Table 1: `integration/Connector:connectionTimeoutMs` moved
from row 16 to row 20. Its own comment names
`connector-connection-timeout-ms-removed`; the permission id appears
there only as a comparison. The code was already right.

## Sibling PRs

- **PR objectstack-ai#20238** (objectstack-ai#20161) has since LANDED as `6a6a17b62`, with the D2
conversion `report-joined-chart-removed` and
`18.ui-report-joined-chart-retired.ts`, which names that id. The union
of this head with `main` at `6a6a17b62` is clean and passes the pin (37
pairs, 0 unnamed; delta review `5859315908`).
- `main` was merged three times with `os-regen-merge.sh`, and never by
hand in a generated region: at `a70cd62e5` (objectstack-ai#20223 and objectstack-ai#20245), at
`21418c4d2` (`cel-predicate-one-value-comparand-refused` and
`filter-query-face-comparands-refused-at-save`) and at `a930cacea`
(step-17 rationale prose from objectstack-ai#20268). Each is D3-only or prose, with no
new step-18 conversion. Regeneration produced a commit only after the
first merge (`3197fce29`) and changed nothing after the other two. Every
sibling entry id was verified present.

## Verification (head `a930cacea`)

- `pnpm check:issue-citations && node
scripts/check-issue-citations.mjs`, exactly as CI runs it (base
`origin/main`): **exit 0**, 112 citations across 29 files: 106 resolve,
6 cross-repo unjudged, 0 findings. At `3197fce29` the same command
exited 2.
- `pnpm --filter @objectstack/spec build` under the verify lock: ok.
`check:generated`: all 15 generated artifacts up to date.
`check:migration-registry`: current (292 semantic, 214 retired-key, 199
retired-def). `spec-changes.json` and `docs/protocol-upgrade-guide.md`
do not move: they project up to the current protocol major, and step 18
is beyond it.
- `pnpm --filter @objectstack/spec exec vitest run --project local`:
**552 files, 16257 passed, 1 todo**. The `src/migrations/` directory
alone: 3 files, 151 passed.
- `pnpm --filter @objectstack/spec typecheck` (tsc, scripts, test layer)
at `21418c4d2`, the head before the last merge, which brought only
another PR's prose into this diff's files: exit 0, test-typecheck debt
unchanged (53 files / 255 errors / 142 signatures).
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at
`a930cacea`, every command run and its exit recorded, reconciled with
`--ran`: **89 derived, 87 run (all exit 0), 2 NOT MEASURED**.
`check:dual-build-cjs-loads` and `check:type-check-debt` exited 3
(PREREQUISITE NOT MET: the full 86-package workspace build does not fit
the foreground cap on this shared box). CI runs both.
`check:pm-dispatch-gates` finished this time: exit 0, in 907.6 s.
- ESLint, narrowed and proven: all 31 changed `.ts` files,
`--no-inline-config --format json`: 0 errors, 0 warnings, none reported
ignored. `eslint.config.mjs` enables no type-aware linting (its own
statement at `eslint.config.mjs:326–328`), so this diff cannot move any
untouched file's verdict.
- Changeset: `@objectstack/spec` `patch`. The published registry text
changes; no accept set moves.

## Acceptance notes (observed, not filed)

- `registry.ts:108` (released step-17 text) still says the sharing-rule
`full` conversion 「leaves no semantic residue」: triage scoped step-17
backfill out, so it is left as is.
- New entries keep tracker numbers in their `//` comments only, never in
the strings an author is shown (AGENTS.md runtime-strings rule). Several
older entries do cite numbers in `reason`; not touched.
- `dashboard-refresh-interval-unit-in-key` states the console renderer's
release lag as a verification step, not as a present fact: this
container has no objectui checkout at the pin to measure it.
- `main` moved after the last merge (`a930cacea`). objectstack-ai#20285 (`2aa25efb4`,
prose in five semantic entries) and objectstack-ai#20238 (`6a6a17b62`, a new step-18
conversion with its entry) landed under `migrations/` and
`conversions/`. The delta review merged this head onto `6a6a17b62`:
clean, with all six regions still mirrored. The queue verifies the
merged generation. (Corrected by the seat at 2026-09-27T19:57Z; the
earlier wording said nothing under those paths had moved.)

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

---------

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

2 participants