Skip to content

feat(spec)!: retire the flattened view overlay's owner and hidden keys (ADR-0049) - #20286

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20230-overlay-owner-hidden-retired
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-20230-overlay-owner-hidden-retired

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20230
Clause-②: no (narrowing)

What this does

Retires the flattened view overlay's owner and hidden keys under ADR-0049 enforce-or-remove. Triage direction on the card (comment 5856621469), verbatim: 「follow #20085's disposition for the same key pair」. PR #20227 retired the same pair on the view item record; this PR retires it on the other door, with the same prescription texts.

The overlay door is the lean PUT /api/v1/meta/view/:name body with no config: members 3 and 4 of the view union (VIEW_METADATA_MEMBERS.listOverlay / .formOverlay), built from flattenedViewOverlayFields() in packages/spec/src/ui/view.zod.ts. It declared both keys, the write door accepted them, and saveMetaItem stored them verbatim. Nothing read either one. After this PR, every door that parses an overlay refuses both keys with the prescription. A stored overlay row that holds either one is stripped on read:

  • a row with other view keys is valid again and re-saves;
  • a hide-only row ({ object, viewKind, hidden: true }) is left identity-only, which the door refuses. It is badged invalid, refused on a whole-row re-save, and reported failed by os migrate meta --stored --apply until it is deleted or given the setting its author meant.

The D2 docblock, the D3 entry and the changeset all state this, and it is pinned.

Stop valve: the writer census, taken first

Taken before any edit, each reading with a lit control on the same ref. No real writer was found, so the retirement proceeds.

where ref writers of overlay owner / hidden lit control
objectui at the .objectui-sha pin f8a9d0fb 0. All 12 view write call sites were read one by one (the persistViewPatch toolbar path, updateView / updateViewConfig / createView in the data adapter, viewEnvelope saves, and the two PublicFormsPage saves). None writes either key. The toolbar's overlay keys are VIEW_OVERLAY_OWNED_KEYS = rowHeight, sort, hiddenFields, columnState, inlineEdit. The switcher writes label and isPinned. 8 persistViewPatch call sites writing the owned keys; 2 updateView row-key writes
objectui main 6cf5999 0 (same 12 call sites, same reading) 7 persistViewPatch call sites; 2 row-key writes
objectstack packages/**, examples/** e46218674 0. Examples author no viewKind at all, and no view-level hidden / owner in 10 view files. No framework source writes an overlay body with either key. label: 88 times in the same 10 example view files
HotCRM 2f7b2326 (= its remote main) 0. No view write call, and no view-level hidden / owner in 14 view files. label: 159 times in those files
cloud not reachable NOT MEASURED. REST read answered 403 and add_repo was refused for this session. #20227's census at cloud 48d70663 recorded no code writer, and one test double that pins a lean {hidden:true} PUT as accepted. That is a test fixture, not a writer. It goes red on cloud's next spec bump only if it parses through the spec schema. —

Readers, re-checked: .hidden / .owner reads on a view in rest-server.ts = 0/0 and in metadata-manager.ts = 0/0. The 5 .hidden reads in metadata-protocol/src/protocol.ts are all field-level. Control: .order is read 2 / 1 / 2 times in the same three files.

Dispatch assumptions, measured

  1. The two keys were at view.zod.ts:5284-5285 on e46218674, and flattenedViewOverlayFields(kind) takes a kind argument. Held. Only those two keys move.
  2. The retiredKey() tombstone applies. Held. Both overlay members .strip(), and a z.never() member refuses loudly instead of stripping: the pins below assert issue code invalid_type at path [key], carrying the prescription.
  3. A D2 conversion is owed. Held, and the view-item entry does not cover it. view-item-owner-hidden-removed skips any body without a config dict, and its own fixture pinned an overlay's hidden: true as kept. This PR adds a separate entry, disjoint by config.
  4. Other view.zod.ts regions were not touched: no edit in FormViewSchema.layout, ViewMetadataParsed or diagnoseViewMetadata.

The route

  • Tombstones. owner: retiredKey(VIEW_ITEM_OWNER_RETIRED) and hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED) in flattenedViewOverlayFields(). These are the view item's own constants, so both doors answer with the same text, as the order asked. A pin asserts the overlay's issue message is byte-equal to the view item's.
  • D2 view-overlay-owner-hidden-removed (toMajor: 18, retiredFromLoadPath: true, lossless stripKeys). Scope: the flattened spelling, meaning a body with no config and no container slot. It walks views (stack sources, and every stored row, which convertStoredItem replays before serving or badging) and viewItems (the assembled channel). It does NOT require viewKind: a flat row stored before 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 has none until the write path heals it in, and then the save would refuse the key it still held. It is wired into MIGRATIONS_BY_MAJOR[18], and the step rationale is extended.
  • Why the D2 matters at runtime, and what it cannot do. objectui's updateView is a read-merge-write, and buildPersistedViewBody re-sends a saved view whole. A stored row served WITH hidden would make the next toolbar toggle a 422, so the read path strips first.
    • For a content-bearing row, that is the whole story.
    • For a hide-only row, the strip leaves identity only, and the door refuses that (the identity precondition: only identity fields). The badge turns invalid, a whole-row re-save or a rename (label is identity) answers 422, and --apply reports failed and leaves the row as stored. A toggle that adds a real key saves.
    • Remedy: delete the row, or add the setting its author meant.
  • D3 view-overlay-owner-hidden-retired (ruling B on [Decision] 一次退役,要写一条记录还是两条?—— 迁移条目的 D2/D3 约定,两处成文相互矛盾 #17152). It names its conversion by id in reason, which is the shape fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255's census pin reads. That pin is now live on main and green here. Its acceptanceCriteria state both classes, the hide-only row included. The view item's pair is a separate family with its own D2 and its own D3 (18.view-item-owner-hidden-retired.ts, from fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255). This PR corrects that entry's one stale sentence (amendment 5859181450).
  • RETIRED_KEYS_BY_MAJOR[18]: ui/ViewMetadata:owner and ui/ViewMetadata:hidden. The overlay members are not exported. ui/ViewMetadata is the exported door they are reached through, and it is listed in unemitted-schemas.baseline.json, so these rows are declared, not judged. The retirement test pins them.
  • No liveness row. The view ledger walks the container keys only (name, label, object, list, form, listViews, formViews), so a row would be an ORPHAN. check:liveness is green without one.
  • Generated artefacts. check:generated: all 15 were current, and there was nothing to regenerate. The four surface ratchets are byte-identical, which is expected on this route: the def is unemitted. spec-changes.json and the upgrade guide project up to protocol 17, so no major-18 entry shows there either (the same reading as PR feat(spec)!: retire the view item's owner and hidden keys (ADR-0049) #20227).
  • Forms / examples / skills / docs. No form offers either key. There are zero authorings in examples/, skills/ and content/docs/. The tree-scoped pin below holds that.
  • Changeset. @objectstack/spec: minor, **BREAKING**, FROM → TO, the one-line fix, Clause-②: no (narrowing), ADR-0087 disposition registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired.

Pins

The new file is packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts (in-package, local project):

  • Both overlay members refuse each key at its path: invalid_type, the path, and the prescription. The view door (getMetadataTypeSchema('view')) refuses with invalid_union, the prescription surfaces as the union's message, and the claimed member locates the key. The assembled channel refuses too.
  • CONTROL: the same overlays without the keys pass every door, with isDefault / order / scope intact and no key grown. The view item door refuses the pair as well, so the family is closed on both doors. defineView is the container door, not an overlay door.
  • D2: a stored row rehydrates clean and then parses at the door, while the unconverted row is refused. A viewKind-less flat row is stripped. The viewItems channel is reached, with each door's key stripped by its own entry. Containers are left alone. Idempotence: the second replay has 0 notices and returns the same reference. Load path: a live author is refused, not rewritten.
  • Registration: the two keys, the chain id, and one D3 record for the conversion. Any other entry naming the conversion must also name view-overlay-owner-hidden-retired, so it is a pointer, never a second record.
  • Hide-only residue (patch round 1):
    • A stored hidden, owner or both row strips to identity only. The door refuses it with the identity precondition's own text, as one custom issue at the root rather than the prescription.
    • A rename (label) is refused.
    • Controls isDefault / order / columnState save.

The ADR-0112 envelope is pinned at the door that produces it. packages/metadata-protocol/src/protocol.save-union-issues.test.ts adds a describe block over the existing stub-engine harness (no new double). For each key and each family, saveMetaItem rejects with code INVALID_METADATA and status 422, persists 0 rows, and carries an issue located at the key with the prescription. CONTROL: the same bound overlays without the keys save, 1 row each.

Patch round 1 adds two pins here:

  • Save door: a whole-row PUT of the stripped hide-only row answers INVALID_METADATA / 422, with 0 rows and "only identity fields". The same row plus isDefault saves.
  • Read path: getMetaItem over a seeded row serves a stored overlay without owner / hidden. _diagnostics is valid when the row carries content and invalid when it is hide-only. The existing harness gains an optional seed; its default is unchanged.

Flipped pins: repo-wide sweep, each one load-bearing

The sweep grepped every test file that spells viewKind beside hidden / owner, in all packages.

pin before after
spec/ui/view-item-owner-hidden-retirement.test.ts BOUNDARY an overlay with the keys parses refused, with the SAME prescription
same file, conversion test overlays left alone the overlay key is stripped by view-overlay-owner-hidden-removed, the record key by the view-item entry (asserted as pairs)
same file, tree-scoped matcher record spelling only both spellings (viewKind + a retired key); anti-vacuity cases for an overlay (TS, YAML) and a container
spec/conversions/registry.ts view-item fixture overlay neighbour kept hidden: true the neighbour carries neither key, which keeps the fixtures disjoint once the overlay entry replays
spec/ui/view-metadata-schema.test.ts a hide PUT accepted; identity + hidden accepted the hide PUT is refused at the member with the prescription (not by the precondition); identity + a live key is accepted, identity + hidden refused
spec/ui/view-union-diagnostics.test.ts overlay.list.aux (with hidden) and put.hidden ACCEPTED moved to REFUSED, plus put.owner; a new test asserts those rows are refused BY the tombstone (the prescription, invalid_type at the key)
spec/conversions/view-spelling-walk.test.ts the overlay's owner survives conversion owner stripped, with the notice under the overlay entry; every binding key still survives
metadata-protocol/src/metadata-diagnostics.union-issues.test.ts {hidden, object, viewKind} badged valid: true badged invalid, with the prescription at hidden; a live key is badged valid

Verification

Patch round 1, final head a05b32f8b, merged with origin/main at 4e0f72e8d, which carries #20238, #20255 and #20244 (dev report 5860055944):

  • spec --project local, full: 553 files / 16341 tests.
  • The touched pins plus migrations.test.ts, with the census pin shown verbosely: 6 / 530.
  • The repo view-item pin: 18/18.
  • turbo build rest^...: 24/24.
  • metadata-protocol save-door + diagnostics: 2 / 40.
  • Typecheck spec + metadata-protocol: exit 0.
  • check:generated: 15/15 current.
  • Gates: 88 derived, 86 run and exit 0, 2 NOT-MEASURED (check:dual-build-cjs-loads, check:type-check-debt: exit 3, PREREQUISITE NOT MET), 0 UNRUN.
  • Ablation (round 1, at e38a8027b): the overlay strip replaced by return view → 6 red (the residue, stored-row, viewKind-less and viewItems pins) / 18 green. The restore was proven by blob == HEAD and an empty git diff HEAD.

The round-0 readings below are at 2a40c104c.

Round 0: final head 2a40c104c. That is after merging origin/main at 17bd3187, which carried #19920's view.zod.ts / assembled-views.zod.ts type change. Heavy runs went through scripts/pm/os-verify-lock.sh, and every exit code was written to disk before its log was read. The box was shared, with lock waits of 3–9 min, so wall-clock readings are contended.

run head reading
turbo run build --filter='@objectstack/rest^...' (spec + the consumer closure) 2a40c104c exit 0, 24/24 tasks
pnpm --filter @objectstack/spec check:generated 2a40c104c exit 0, all 15 artifacts current; nothing regenerated
spec --project local, full 2a40c104c 553 files / 16275 tests passed
spec --project repo, view-item-owner-hidden-retirement.test.ts (tree-scoped pin) 2a40c104c 18/18 passed
metadata-protocol, the edited files + view-write-path-identity.test.ts 2a40c104c 3 files / 41 tests passed
typecheck: spec (tsc + scripts + check:test-typecheck), lint, metadata-protocol 2a40c104c exit 0 ×3
consumers, full: metadata-protocol / lint / metadata; objectql and rest (their 24 / 13 view files) cbc81c574 189 files / 2720 tests (3 skipped) · 110 / 4262 · 54 / 821 · 24 / 380 · 13 / 191, all exit 0

Reverse verification (a one-shot probe removed by an EXIT trap, verified absent afterwards): packages/lint/src/zz-issue20230-dts-probe.ts typed { object, viewKind: 'list', hidden: true } as ViewMetadata, against the REBUILT spec .d.ts. @objectstack/lint tsc --noEmit exited 2: src/zz-issue20230-dts-probe.ts(2,14): error TS2322: Type '{ object: string; viewKind: "list"; hidden: boolean; }' is not assignable to type 'ViewMetadata'. With the probe removed, git status showed 0 lines and lint typecheck exited 0. Predicted direction: red. Observed: red.

Ablation (scripts/ablation-replace.mjs, on committed state, wrap mode). The mutation swapped the overlay's hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED), for hidden: z.boolean().optional(),: anchor 1 → 0, blob 1f93b520 → e9ad3dec. Three spec files then read 10 failed / 139 passed, and the 10 are exactly the overlay hidden pins: both members, the same-text pin, the door, the assembled channel, the hide-PUT refusal, the identity pin, and the three union-diagnostics rows. The owner pins stayed green, as they should. The restore brought the blob back to HEAD 1f93b520, with git diff HEAD at 0 bytes and git status --porcelain at 0 lines. Predicted direction: red. Observed: red. (The metadata-protocol save-door pins resolve spec through dist/, so they were not part of this ablation.)

Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 2a40c104c derived 88 commands. All 88 ran with the exit code captured before any pipe, and were reconciled with --ran: 88 derived, 86 run, 2 NOT-MEASURED, 0 UNRUN. All 86 measured commands exited 0. That includes check-adr-0087-registration (registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired (new here …), [BREAKING+bang+clause-②-narrowing]), check-changeset-no-major, check-empty-changeset, check:nul-bytes, check:cross-package-test-inputs, check:doc-authoring, and the spec check:* family (check:authorable-surface, check:liveness, check:migration-registry, check:spec-changes, check:upgrade-guide, check:api-surface, check:docs).

NOT MEASURED (exit 3, PREREQUISITE NOT MET; each reads built output of the whole workspace, which was not built locally): pnpm check:dual-build-cjs-loads, pnpm check:type-check-debt. This diff touches no package entry point, export or tsconfig. CI's build lanes measure both. Also owned by CI: pnpm lint, the remaining objectql / rest suites, and the lanes dispatch-gates lists outside the derived total. The CLI integration tier does not apply (no packages/cli change).

Acceptance notes (observed, not fixed here)

The seat updated this body at 2026-09-27T21:40Z after patch round 1, per dev report 5860055944. Reviews: 5859174998 (FAIL at 2a40c104c).

  1. One family or two for D3, and the overlap with PR fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255. PR fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255 (Major 18 retirements justified "lossless, so no semantic residue" carry no D3 entry, which ruling B on #17152 now requires (tenancy.organizationField, and a census of the rest) #20201, not merged when this opened) adds 18.view-item-owner-hidden-retired.ts as the view item family's D3 entry, and a census pin requiring every major-18 conversion to be named by a D3 entry of its step. This PR's conversion is separate, disjoint by config, so it carries its own D3 entry naming it. That keeps one record per conversion and no second file under fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255's filename, which would be an add/add collision. fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) #20255 has since landed (f415bcf18), and the census pin is green here at a05b32f8b. Its sentence 「A flattened view overlay keeps its own owner and hidden …」 is replaced in this PR (amendment 5859181450): the overlay pair is a separate family, with its own D2 view-overlay-owner-hidden-removed and D3 view-overlay-owner-hidden-retired.
  2. This PR supersedes one sentence of feat(spec)!: retire the view item's owner and hidden keys (ADR-0049) #20227's pending changeset. .changeset/view-item-owner-hidden-retired.md says an overlay "still parses". It is left as landed, because check-empty-changeset refuses an edit to another PR's release note. This PR's changeset states the supersession instead. The release compiler should read the two together.
  3. Cloud is NOT MEASURED (above). If its mock-protocol double parses {hidden:true} through the spec, it goes red at cloud's spec bump. That is a fixture edit there. Carrier: cloud, at its next @objectstack/spec bump.
  4. The assembled channel's refusal loses the branch diagnostics (feat(spec)!: retire the view item's owner and hidden keys (ADR-0049) #20227's acceptance note 4, pre-existing): AssembledViewArtifactSchema is a plain z.union. Carrier: none.

Generated by Claude Code

…s (ADR-0049)

Tombstone both keys on the two flattened overlay members with the view
item's own prescription texts, add the D2 conversion
view-overlay-owner-hidden-removed (views + viewItems, flattened spelling),
wire it into step 18, register ui/ViewMetadata:owner|hidden, and add the
family's one D3 semantic entry view-owner-hidden-retired.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
… and flip the overlay pins

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…tem entry's overlay paragraph

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…-owner-hidden-retired)

The view item record's pair is a separate family with its own D2 conversion
and its own D3 entry; this door's entry names view-overlay-owner-hidden-removed.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…session in this entry

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

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 13 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__hidden.ts, packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__owner.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/ai/actions-as-tools.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/ai/agents.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/api/client-sdk.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/api/error-catalog.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/automation/hook-bodies.mdx (via crm_deal (literal, a string literal in fixture))
  • 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/data-modeling/index.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/deployment/validating-metadata.mdx (via crm_lead (literal, a string literal in fixture))
  • 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/permissions/authorization.mdx (via crm_lead (literal, a string literal in fixture))
  • 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/ui/forms.mdx (via /api/v1/meta/view/:name (route, a path literal in semantic; a path literal in surface))
  • content/docs/ui/index.mdx (via crm_lead (literal, a string literal in fixture))

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

  • content/docs/releases/v13.mdx (via crm_lead (literal, a string literal in fixture))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__hidden.ts, packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__owner.ts) — pages documenting those are invisible to this run
  • 8 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 4e0f72e8d24c67705ce278de24bc4166ba3f1cd6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 4e0f72e8d24c67705ce278de24bc4166ba3f1cd6

⚠️ 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 4e0f72e8d24c67705ce278de24bc4166ba3f1cd6 → 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: 2a40c104c761e1bf6b82621b904cb4e63cd4cace

Read: card #20230 (body + comments 5856621469, 5857046922, 5857385541, 5858660982); #20085 and PR #20227's merged diff (view.zod.ts, conversions, migrations, changeset); ruling B 5615360777; PR #20286 object, body, 14-file list, 8 commits, full diff vs merge base 17bd318, 35 check-runs; PR #20255 at its current head a930cac (D3 entry, census pin, generator change); the head's retiredKey, overlay members, mapViewPayloads, stripKeys/mapCollection, applyConversionsToStoredItem, protocol convertStoredItem / getMetaItem / migrateStoredMetadata, build-schemas.ts retired-key checks, generator render loop, the three changeset gate headers, AGENTS.md §3. Ran (worktree at the head, all through os-verify-lock): install, closure build (12 tasks), the PR's 5 spec pin files + 2 metadata-protocol files (311 + 37 tests green), the repo-project tree-scoped pin, a whole-table replay probe, an identity-residue probe, an end-to-end read-path probe with a seeded engine row, a migrateStoredMetadata --apply probe, an owner ablation (7 red / 164 green), a textual re-render of both step-18 generated regions (byte-identical), three git merge-tree --write-tree runs, and writer censuses of HotCRM 2f7b2326, packages/examples at the head, objectui main fda49e5 and pin f8a9d0fb (anonymous shallow clone). NOT MEASURED: cloud (not reached); the end-to-end getMetaItem strip is measured by my probe only, not by a PR pin.

① Derived judgments

(a) Stop valve — HELD, 0 real writers. HotCRM 2f7b2326: 14 view files, 0 files with viewKind outside CHANGELOG.md, 0 top-level hidden:/owner: in *.view.*, 0 view write calls (control label: 159 hits). packages/** at the head: the one structural hit is the new D2 fixture itself; readers .hidden/.owner on a view in rest-server.ts and metadata-manager.ts 0/0 (control .order 2/1). examples/**: 0 files with viewKind, 10 view files (control label: 88). objectui main fda49e5 (newer than the dev's 6cf5999) and pin f8a9d0fb: 17/18 write call sites (persistViewPatch x8/x9, updateView x2, updateViewConfig, buildPersistedViewBody) write none of the two keys; VIEW_OVERLAY_OWNED_KEYS = rowHeight/sort/hiddenFields/columnState/inlineEdit; switcher writes label/isPinned. Cloud NOT MEASURED. Method: sound for the stop valve. A PUT of the whole view is a relay, not an author: objectui mergeViewPatch spreads the served row wholesale, so a stored key would be re-sent, which is exactly the read-path strip's business (measured end-to-end below). The assembled viewItems channel is machine-assembled from rows/sources, covered by the D2 on that key. The census cannot see a spread/computed-key writer, which the PR states as its own bound.

(b) Refusal — HELD at every door, with one residue class stated under (c). Both overlay members refuse each key with invalid_type at [key] and the prescription; getMetadataTypeSchema('view') (= ViewMetadataSchema) refuses with invalid_union carrying the prescription and the claimed member locating the key; AssembledViewArtifactSchema refuses. saveMetaItem answers INVALID_METADATA/422, persists 0 rows, issue at the key (PR pin, run by me: 37 tests green). Byte-equal text: the overlay uses the view item's own constants VIEW_ITEM_OWNER_RETIRED / VIEW_ITEM_HIDDEN_RETIRED, and the pin asserts the two members' messages toBe equal. Stored rows stripped on read: getMetaItem calls convertStoredItem on overlay and draft rows, which calls applyConversionsToStoredItem(..., { includeRetired: true }); measured end-to-end with a seeded engine row {object, viewKind, isDefault, order, owner, hidden}: served without both keys, and the console's read-merge-write ({...served, isPinned: true}) saves without them. Pinned by the PR at the spec seam and at the badge, not through getMetaItem (the save-door harness engine serves no rows).

(c) D2 view-overlay-owner-hidden-removed — scope guard is exactly the five z.undefined() guards the overlay members declare (config, list, form, listViews, formViews); disjoint from view-item-owner-hidden-removed by construction (item needs isDict(config), overlay needs config === undefined); viewKind not required (a pre-#7741 flat row is stripped, pinned and re-measured). Fixture-neighbour edit is FORCED: conversions.test.ts replays the WHOLE table with includeRetired over every fixture and requires the declared after, so the neighbour's hidden: true would have been stripped by the new entry. My replay: whole table over both fixtures fires only the owning entry and equals after; an exhaustive 4 viewKind x 6 config x 5 slot grid finds 0 objects judged by both; the only shapes neither strips whose key-free twin is legal are containers (form/formViews slot) with top-level keys, a shape no door ever accepted (strict ViewSchema), so no stored row can hold it; five legacy overlay shapes are stripped in views and in viewItems; second replay is a no-op by reference; both entries retiredFromLoadPath. FINDING (the residue): the card's own measured shape, a hide-only row { object, viewKind, hidden: true }, strips to { object, viewKind }, an identity-only body that the view door refuses by the #7741 precondition ("only identity fields"). Measured through the protocol with a seeded row: served as {object, viewKind} with _diagnostics.valid: false; computeMetadataDiagnostics badges it invalid; a GET-then-PUT of that row answers 422 INVALID_METADATA; migrateStoredMetadata({ apply: true }) records outcome failed with 0 writes (the content-bearing row records rewritten). The console's merge (a toggle adds a key) still saves. So the D2 does not collide and does not miss the shape, but the family's disposition of that shape is unstated and its written promises are false for it (see d, ②). Before the PR the same row was badged valid (the PR's own metadata-diagnostics diff shows the prior pin).

(d) D3 view-overlay-owner-hidden-retired — one entry, separate from the view-item family's (#20255's 18.view-item-owner-hidden-retired.ts): correct under ruling B, one D3 per family, the two families having distinct D2s and distinct doors; #20255's census pin pairs by conversion id, and the PR's own pin asserts exactly one claimant of the overlay id. It names view-overlay-owner-hidden-removed as a whole id in both the leading comment and reason, inside backticks, which #20255's (?<![a-z0-9-])id(?![a-z0-9-]) matcher pairs. Its judgment (lossless delete, exposure, measured zero in-repo writers, NOT MEASURED population) is true. Its acceptanceCriteria are FALSE for the hide-only residue: "a GET then a PUT of that row answers 200" (measured 422) and "os migrate meta --stored --apply persists the stripped shape" (measured failed, nothing persisted, the key stays at rest). The same over-claim sits in the D2 docblock and the retired-key comment ("survives the console's next read-merge-write" is true only because the console adds a key).

(e) Retired keys under ui/ViewMetadata — the two members are not exported and VIEW_METADATA_MEMBERS is not a Zod export, so the exported door is the only def key available; UI.ViewMetadataSchema is in unemitted-schemas.baseline.json (export-name spelling; the PR's "ui/ViewMetadata is listed" is the def-key paraphrase) and authorable-surface/ui.json carries 0 ui/ViewMetadata lines. build-schemas.ts reads the rows: check (b2) fires only for a key the build emits live (currentKeys.get(k) === false), (b3) only for nested rows, so these rows are declared, not judged, as the PR says and as #20227's discriminated-union rows were; check-widening-tells.mjs reads them as narrowing evidence. Generated regions: my re-render of entries/semantic/18.* and entries/retired-keys/18.* (sorted by id, comment verbatim, 4-space indent, trailing comma) is byte-identical to both step-18 regions (9917 and 3397 lines); the three new entries appear verbatim. conversionIds and the rationale are hand-kept fields outside the markers (as in #20227), correctly hand-edited.

(f) Pin sweep — all 8 flipped pins assert the new semantics (replaced assertions, not deletions): hide PUT refused at the member with the prescription and not.toContain('Not a view body'); identity + order accepted, identity + hidden refused; union-diagnostics moves overlay.list.aux(with hidden) and put.hidden to REFUSED, adds put.owner, and a test that all three are refused BY the tombstone; view-spelling-walk asserts owner gone with the notice path under the overlay entry and every binding key kept; metadata-diagnostics badges hidden invalid at the key with a live-key control; the view-item file's BOUNDARY moves to refusal with the same text, the conversion pins pairs, and the matcher gains the overlay spelling with anti-vacuity cases. Genuinely-illegal rows and the #7741 blocks are untouched (additions only). owner is load-bearing: swapping the overlay owner tombstone for z.string().optional() turned 7 pins red (both members, same-text, door, assembled channel, put.owner, tombstone check) with 164 green; the file restored to blob 26093678. The repo-project tree-scoped pin flagged only my own probe files as offenders (anti-vacuity shown), 17/18 otherwise.

(g) Surface fence — inside claim 5857385541. The view.zod.ts hunks are the two keys plus three comment-only edits (the shared prescription docblock, the view-item tombstone comment, the assertViewIdentity docblock); #20227 likewise edited a docblock outside its landing site (ViewScopeSchema). No FormViewSchema.layout, ViewMetadataParsed or diagnoseViewMetadata hunk. conversions/registry.ts: the new D2 is owed by the claim; the view-item docblock and fixture-neighbour edits are forced by the whole-table fixture gate. .changeset/view-item-owner-hidden-retired.md: 0-byte diff vs the merge base (not in the file list).

② Semver level

@objectstack/spec: minor, **BREAKING**, Clause-②: no (narrowing), <!-- adr-0087: registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired -->, FROM -> TO table and the one-line fix. AGENTS.md §3: (narrowing) is BREAKING; check-changeset-no-major header: during the launch window breaking ships as minor, carried by the banner and the ADR-0087 disposition, and the level axis stands down for a no declaration; check-adr-0087-registration header: exactly one disposition marker, registered <id>[, <id>], both ids exist (D2 in CONVERSIONS_BY_MAJOR[18], D3 in step-18 semantic). Same grade as #20227, and Check Changeset is green. Correct level. One content defect: the "Stored rows" paragraph promises a stored row holding either key is "badged valid" and re-saves, which is false for the hide-only residue (c).

③ Boundary flags

Blocking: the hide-only residue under (c)/(d). The card's measured shape { object, viewKind, hidden: true } is the one stored class this retirement cannot bring back into the accept set: after the strip it is identity-only, the door refuses it (#7741), the badge turns from valid to invalid, a whole-row GET-then-PUT answers 422, and os migrate meta --stored --apply reports it failed and leaves the key at rest. The D3 acceptance criteria and the changeset assert the opposite for it. Remedy, either arm: state the residue in the D3 acceptanceCriteria, the changeset and the D2 docblock (delete the row or add a personalization key; --apply reports it), regenerate the region, and pin the residue in view-overlay-owner-hidden-retirement.test.ts and the save-door file; or route the residue through #20244's stored-conversion TODO channel. Cloud's mock-protocol double pins exactly this shape.
Non-blocking: (1) Landing order with #20255: its 18.view-item-owner-hidden-retired.ts sentence "A flattened view overlay keeps its own owner and hidden: those live on a different door that this retirement does not touch" is false once #20286 lands. Whichever lands second corrects it: one sentence in the entry FILE suffices only with gen:migration-registry re-run, never a hand edit of the region. (2) git merge-tree --write-tree: #20286 x #20255 clean (4f6d725d); #20286 x #20244 clean (4ed5a307); #20286 x #20238 CONFLICT in conversions/registry.ts (the CONVERSIONS_BY_MAJOR[18] append, viewOverlayOwnerHiddenRemoved vs reportJoinedChartRemoved) and migrations/registry.ts (the step-18 rationale string and the conversionIds append). All three hunks are in hand-kept fields; no <os-generated> region conflicts, they auto-merged. #20238's side of the rationale still carries the "keeps its own owner / hidden" sentence #20286 removes; the second lander must not restore it. (3) Cloud { hidden: true } pin: NOT MEASURED; a cloud fixture, red only at cloud's next spec bump; carrier cloud. (4) AssembledViewArtifactSchema plain union: pre-existing (#20227 note 4); it has no identity precondition, so the assembled channel accepts the identity-only residue, and viewItems is not affected by the blocking finding. (5) The end-to-end getMetaItem strip has no PR pin; measured here with a seeded engine double. (6) #20255's head moved to a930cac since dispatch; its entry text is unchanged.

CI at this head: 35 check-runs on 2a40c10, all completed: 32 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failures; required lanes Build Core, Test Core 1-6, Type Check x5, Lint & Repo Gates, Spec property liveness, Check Changeset, Dogfood gates all green. PR is a draft, mergeable_state: blocked (awaiting the review record). Closing keywords: the body carries exactly one, Fixes #20230.

Implemented-by: claude/issue-20230-overlay-owner-hidden-retired
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: FAIL

…rip truthfully, and pin it

A stored overlay row that held nothing but identity and owner/hidden is
left identity-only by the strip, which the view door refuses: badged
invalid, refused on a whole-row re-save, reported failed by --apply. The
D2 docblock, the step-18 rationale, the D3 entry and the changeset now say
so for both classes; the spec pin and the save-door pin hold it, and the
save-door harness gains an optional seeded read to pin getMetaItem's strip.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…erlay-owner-hidden-retired

# Conflicts:
#	packages/spec/src/conversions/registry.ts
#	packages/spec/src/migrations/registry.ts
…amily

After #20255 landed, its view-item D3 entry still said a flattened overlay
keeps its own owner/hidden. The overlay pair is a separate family with its
own D2 view-overlay-owner-hidden-removed and D3 view-overlay-owner-hidden-retired.
The overlay pin now reads another entry naming the conversion as a
cross-reference that must point at this family's record.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a05b32f8b649e888783c44c92240614f832abbc4

Delta from 2a40c10 (FAIL 5859174998): five commits — 24789bb (merge → 733822c), e38a802 (residue text + pins), 73c67e2 (merge → 28ad7e4, the #20238 conflicts), 2713d1b (merge → 4e0f72e, #20255 / #20244), a05b32f (the #20255 sentence); three merges, not two. Read: PR object, body, 15-file list, 13 commits, check-runs and combined status; card comments 5859181450, 5860055944, 5859174998; the prior record; the head's D2 docblock, D3 entry, view-item entry, both retired-key entries, changeset, step-18 rationale, assertViewIdentity / speaksViewVocabulary, migrateStoredMetadata and its re-save, sys-metadata-repository parent check, the save-door harness, the #20255 census pin, the two changeset gate headers, check-engine-double-contract and its pinned ledger, os-regen-merge.sh. Ran: a normalised interdiff of the two PR diffs against their merge bases (17bd318 / 4e0f72e); git merge-tree --write-tree of each merge's parents and of the head against origin/main de091b5; a detached worktree at the head with pnpm install --frozen-lockfile; through os-verify-lock: the closure build (13/13, cache-restored), the PR's 5 spec pin files + migrations.test.ts (319 green) and the 2 protocol files (40 green), gen:migration-registry / gen:spec-changes / gen:upgrade-guide (tree byte-clean), a protocol probe with a checksum-seeded engine double (--apply, preview, GET-then-PUT, rename, toggle, unconverted PUT), a spec probe (door, assembled channel, strip), a claimant-pin probe (four variants), and three ablations (strip → return view; precondition → return true at source; the same with the spec dist rebuilt for the protocol pins), each restored and blob-checked. NOT MEASURED: cloud (not reached); the full spec / consumer suites (CI-owned, green); the stored population behind --apply is a seeded double, not a live store; objectui's actual PUT envelope (a whole-row PUT was modelled without parentVersion).

① Derived judgments

(a) Residue text — TRUE at the head for both classes, in all five places. Content-bearing row ({ name, object, viewKind, isDefault, order, owner, hidden }, seeded with its checksum): getMetaItem serves it without either key, _diagnostics { valid: true }; a GET-then-PUT of the served body saves; migrateStoredMetadata({ apply: true }) records rewritten, one sys_metadata update, stored body afterwards { name, object, viewKind, isDefault, order }. Hide-only rows (hidden, owner, and label + hidden): served identity-only, _diagnostics.valid: false carrying the precondition's text; a whole-row PUT answers 422 INVALID_METADATA "only identity fields"; a rename (+ label) answers 422; --apply records failed for all three with "only identity fields", zero writes, the key still at rest; preview writes nothing. A toggle (+ isDefault) saves, and at the schema seam isPinned, sortOrder, order, columnState all pass — "any other view key" holds because the precondition tests the union's vocabulary minus the four identity keys. A PUT that still carries hidden is refused by the tombstone prescription, not the precondition. "Badged valid before this release": the flipped diagnostics pin's previous assertion at the merge base asserted { valid: true } for { hidden: true, object, viewKind }. So the D2 docblock, the step-18 rationale, the D3 reason / acceptanceCriteria, and the changeset's "Stored rows" and NOT-MEASURED paragraphs are each true sentence by sentence; the retired-key comments were not rewritten this round and carry no stored-row disposition claim.

(b) New pins — load-bearing. Strip ablated (return view, anchor 2 → 0): 6 red / 18 green, exactly the three residue pins, the stored-row pin, the viewKind-less pin and the viewItems pin — the dev's e38a802 reading reproduced at the head. Precondition ablated at source (assertViewIdentity returns true): 4 red / 20 green — the three residue pins ("door refuses", r.success came back true) and the rename pin; every control stayed green. Precondition ablated with the spec dist rebuilt (1 task, uncached): the protocol file reads 2 red / 23 green — RESIDUE (the identity-only PUT then saves) and READ PATH (the hide-only row badges valid); RESIDUE CONTROL and the bound-overlay CONTROL saves stay green. The save-door pin is therefore on the door, and the read-path pin on the badge. Harness seed: seed defaults to [], under which findOne runs assertEngineFindOnePredicate(object, query) and returns null and registry keeps its two methods, as before; the seeded branch only adds a lookup after the predicate. engine-double-contract.pinned.json still carries the file's delete / findOne / update rows, and the predicate is the first statement, so the check still sees a pinned double (read, not run). Blobs after every restore equal HEAD (922080b5, 26093678, 826dd727).

(c) Loosened claimant pin — sound, and it still catches a second record. A fake 18.zz-review-second-overlay-record.ts naming the conversion as its own judgment: regenerated, the pin goes red naming the fake. The same fake with a see view-overlay-owner-hidden-retired pointer passes: that is the residual blind spot, a second record that also cites the real one, a contradiction on its face for any reader. The loosening was forced: the old toEqual([...one id]) assertion is red at the head itself (2 namers, the view-item entry being the amendment's sentence), and #20255's census requires at least one namer and states it judges ownership by reading. Registry restored byte-equal after the probe.

(d) The #20255 sentence — true at the head: the overlay pair has its D2 in CONVERSIONS_BY_MAJOR[18] and its D3 in step-18 semantic; the old "keeps its own owner and hidden" wording has 0 hits at the head in the entry file and in migrations/registry.ts. Mirror regenerated: gen:migration-registry (295 / 217 / 199) plus gen:spec-changes and gen:upgrade-guide leave git status --porcelain empty, and a05b32f's registry hunk is the entry hunk verbatim at the generator's indent. The amendment's condition holds (f415bcf is inside 4e0f72e). Census pin: migrations.test.ts green in the 319-test run.

(e) Merges. 24789bb and 2713d1b: commit tree identical to merge-tree --write-tree of the parents (f3954212, aa65966d). 73c67e2: merge-tree exits 1 on exactly conversions/registry.ts and migrations/registry.ts; the commit differs from the conflict tree in those two files only. Resolution: CONVERSIONS_BY_MAJOR[18] and conversionIds take reportJoinedChartRemoved / report-joined-chart-removed first, then the overlay entry; the rationale keeps #20238's chart paragraph and this PR's overlay paragraph, and the removed "a flattened overlay keeps its own owner / hidden" sentence is NOT restored (present on 28ad7e4 / 4e0f72e / de091b5, absent at the head). One further hand edit inside the conflict hunk: the overlay paragraph's lead-in "It then retires the same pair on the flattened overlay door" became "It retires the view item's owner / hidden pair on the flattened overlay door too", which restores the antecedent the inserted chart paragraph displaced — a resolution, in a hand-kept field. No generated region was hand-merged: the clean-merge → commit diff touches no <os-generated> region and the regen is byte-clean.

(f) Nothing else moved. The normalised interdiff (469 lines) touches only: the changeset's "Stored rows" and NOT-MEASURED paragraphs; the D2 docblock; the D3 reason / acceptanceCriteria; the rationale and the two main-first list resolutions; the view-item sentence and its mirror; the harness seed, its import and the three protocol pins; the residue describe block, one comment, and the loosened claimant assertion. view-union-diagnostics.test.ts and metadata-diagnostics.union-issues.test.ts appear as unchanged context only.

② Semver level

Unchanged: '@objectstack/spec': minor, **BREAKING**, Clause-②: no (narrowing), <!-- adr-0087: registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired -->, FROM → TO table and the one-line fix. Both ids exist at the head (D2 in CONVERSIONS_BY_MAJOR[18], D3 in step-18 semantic). Gate headers read, families not run; Check Changeset is green twice on the head. The one content defect the prior record found in the changeset is corrected (a).

③ Boundary flags

Blocking: none
Non-blocking: (1) Merge risk: merge-tree --write-tree a05b32f8 de091b50 (#20310, #20315, #20319, #20314) is clean, tree 195f2cf2; #20322 is not on main. (2) Claimant pin blind spot under (c): a second record that also cites the real D3 id passes; a reader would see the contradiction. (3) The PR body says round 1 merged origin/main at 4e0f72e carrying #20238, #20255, #20244, which is true cumulatively; the dev report's "second merge brought #20238, #20255 and #20244" conflates 73c67e2 (#20238, #20266, #20277) with 2713d1b (#20255, #20244, #20302, #20304). Three merges sit after 2a40c10. (4) Body / changeset switcher-key lists differ (label, isPinned vs label, isPinned, isDefault, sortOrder): pre-existing, not delta. (5) Cloud {hidden:true} fixture: still NOT MEASURED, carrier cloud. (6) AssembledViewArtifactSchema plain union, measured at the head: accepts identity-only { object, viewKind } and { name, object, viewKind }, refuses identity + hidden with a rootless "Invalid input". So on the assembled channel a hide-only overlay after the strip registers rather than being refused; pre-existing (#20227 note 4), and the PR's residue text names the view door only, which is accurate. Carrier none. (7) mergeable_state reads unstable with no red check-run and a success combined status (Vercel only).

CI at this head: 42 check-runs on a05b32f, all completed: 37 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failures; Build Core, Test Core 1–6, Type Check ×5, Lint & Repo Gates, Spec property liveness, Check Changeset ×2, Dogfood ×5, Temporal Conformance, Governed Surface Queue Guard, the claim guards all green. Combined status success. Closing keywords: exactly one, Fixes #20230.

Implemented-by: claude/issue-20230-overlay-owner-hidden-retired
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 22:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit eea8787 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20230-overlay-owner-hidden-retired branch September 27, 2026 22:27
os-sales pushed a commit that referenced this pull request Sep 27, 2026
Both registries gained an entry on each side on the same lines
(`view-overlay-owner-hidden-removed` from #20286, this branch's
`flow-decision-mode-inclusive-explicit`); both kept, landing order.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ind carry the shapes their doors accept (objectstack-ai#19920) (objectstack-ai#20369)

Part of objectstack-ai#19920
Clause-②: yes (narrowing)

This PR takes the four remainder items that seat 1's release on objectstack-ai#19920
(comment 5858219255) lists, plus remainder 5 (the flattened list
overlay's `type` and `columns`), on `main` after PR objectstack-ai#20286. objectstack-ai#19920
remains open because this round's census and probes found three more
sites of the same family, outside this PR's surface. See "What stays on
objectstack-ai#19920" below.

## What changed

Only types change. The runtime accept set does not move: no schema's
parse, no value, and no removed or renamed export. The FROM column was
probed on the base `c5dcb3ba0`, both against the source (compiler API,
`tsconfig.test.json` options) and against the built `dist`.

| item | FROM | TO |
|:--|:--|:--|
| 1. `JoinedReportBlock` (`ui/report.zod.ts`) | `unknown`, because
`JoinedReportBlockSchema` was annotated `z.ZodTypeAny`. Each `blocks[]`
element of `Report` / `ReportParsed` (and so of `defineReport`'s
parameter) was `unknown` for the same reason. | The schema's own input
type: the annotation is removed. `blocks[]` elements are blocks. |
| 2. A ViewItem's `config` (`ui/view.zod.ts`) | `unknown` on both arms
of `ViewItem` and `ViewItemWire`, because `viewItemArmShape(viewKind,
config)` typed `config` as `z.ZodTypeAny`. The same `unknown` reached
`defineViewItem` and the `viewItem` member of `ViewMetadata`,
`ViewMetadataParsed`, `AssembledViewArtifact` and
`AssembledViewArtifactParsed`. | The arm's config type: a `ListView`
config under `viewKind: 'list'`, a `FormView` config under `viewKind:
'form'`. `config` is a generic parameter now, beside `viewKind`. |
| 3. The flattened overlay members' `viewKind` | The union of `'list'`
and `'form'` on both members, because `flattenedViewOverlayFields(kind)`
was not generic, so `z.enum([kind])` widened. `{ type: 'grid', columns:
['name'], object, viewKind: 'form' }` type-checked, through the list
member, as all four union types above, while both doors refuse it. |
`'list'` on the list overlay member, `'form'` on the form overlay
member: the function is generic in the kind. |
| 4. The list overlay's `type` default | Not named anywhere. | One TSDoc
paragraph on `ViewMetadataParsed`, and the same on
`AssembledViewArtifactParsed`. It says, from the code, that the member
declares `type` without `.default('grid')` and re-applies it in
`.overwrite(applyListOverlayTypeDefault)`. An `.overwrite()` returns the
member's own output type, so `type` stays optional in the output type
while every parse through that member returns it (`'grid'` when the body
named none). On that member `type` is typed as the list shape's `type`
enum. |
| 5. The flattened list overlay's `type` and `columns`
(`ui/view.zod.ts`) | `unknown` on the list overlay member of
`ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and
`AssembledViewArtifactParsed`, on input and on output, because
`listOverlayPatchFields()` read both keys off `ListViewShapeSchema`
through a cast of its shape to a record of `z.ZodTypeAny`. `{ object,
viewKind: 'list', columns: 42 }` type-checked as all four while that
member refuses it. | The list shape's own types, both optional: `type`
the list view type enum, `columns` the field list. The shape is read as
typed; the schemas are the same objects, so no parse moves. |

### Changes beyond the five items, each forced, with what forced it

1. **Declaration size (item 2).** With only the generic parameter,
`view.zod.d.ts` grows by +170,655 B (500,535 to 671,190, +34%), with 0
TS7056. The cause is that `ViewItemSchema`, `ViewItemWireSchema` and
`VIEW_METADATA_MEMBERS.viewItem` each spell both config types out in
full, at 58,764 B, 59,326 B and +60,108 B.
- **What this PR does instead.** Both schemas carry an explicit type
annotation through two non-exported aliases read off `viewItemArmShape`
itself. `ViewItemArmShape` is the ReturnType of `typeof
viewItemArmShape`, instantiated. `ViewItemWireArmShape` is that plus
`viewItemWireFields()`, written as one mapped type. The `viewItem` entry
of `VIEW_METADATA_MEMBERS` is spelled `ViewItemWireSchema as typeof
ViewItemWireSchema`, an assertion to the schema's own type, so the
emitter names the schema instead of copying it.
- **Result.** `view.zod.d.ts` is 496,008 B, 4,527 B smaller than on the
base.
- **Identity proof.** Measured with an `Equal` probe (the
mutual-conditional form) against in-memory inferred twins of both
schemas. It holds for each schema, for its z.input and z.output, and for
the member. A control pair answers false.
- **A spelling I measured and did not take.** A first spelling of the
wire annotation used an intersection. It was assignable but not
identical (Equal false), so it was replaced by the mapped form.
- **The deviation.** No TS error forced this assertion. Declaration size
forced it. So it deviates from the dispatch's mechanism assumption 2,
which allowed assertions only where a TS error forces them.
2. **ADR-0122 (items 1 and 2).** Three isomorphism pins in
`type-alias-convention.pin.test.ts` turned into TS2344 ×3:
`Iso_ui_report__JoinedReportBlockSchema`, `Iso_ui_view__ViewItemSchema`
and `Iso_ui_view__ViewItemWireSchema`. They had held only because input
and infer were the same erased `unknown`; typed, each schema carries
defaults. The pins are deleted. Per `check:spec-parsed-alias` rule 2,
three parsed-state aliases are added: `JoinedReportBlockParsed`,
`ViewItemParsed` and `ViewItemWireParsed`. These are three new type
exports on the `ui` entry; `api-surface/ui.json` and
`export-origins/ui.json` were regenerated. The pin count is restated
from 786 to 783 in the file's two prose places and its count history.
3. **`test-typecheck-debt.json`.** The `report.test.ts` entry shrinks by
two signatures (TS18046 "'b' is of type 'unknown'" and TS2571), because
`blocks[]` is typed now. It was regenerated with
`gen:test-typecheck-debt`.
4. **A pending release note corrected on purpose.** See the next
section.

## Confirmation needed: a pending release note is corrected on purpose
(`Check Changeset` stays red)

- **The note.** `.changeset/19920-exported-types-not-unknown.md` is PR
objectstack-ai#20260's pending entry for this card. It ended "`JoinedReportBlock` is
not changed by this change, and still resolves to `unknown`."
- **What this PR changes under it.** Item 1 makes the second half of
that sentence false in any release that carries both entries.
- **The rewrite.** The sentence now reads "`JoinedReportBlock` is not
changed by this change. It stops resolving to `unknown` in its own entry
(objectstack-ai#19920)." That holds whichever release carries either entry. Nothing
else in the note moves.
- **What the gate says.** `node scripts/check-empty-changeset.mjs --base
origin/main` exits 1, naming exactly this file, in its DELIBERATE
CORRECTION class. Following the gate's prescription, the file is not
restored from the base, which would put the false sentence back.
- **What happens next.** It stays red until someone confirms the
correction here. If a release consumes that entry before this PR lands,
the merge keeps `main`'s deletion and the correction is moot.

The new entry is `.changeset/19920-exported-types-remainder.md`:
`@objectstack/spec` `minor`, `Clause-②: yes (narrowing)` (the diff
narrows published types and also adds three exported type names, per the
at-tier record 5863387565), a BREAKING banner naming every type that
narrows, FROM and TO per item (four bullets, remainder 5 included), a
banner and ADR-0087 reason saying that no EXISTING export changes, and
the ADR-0087 marker `not-required (no-migration-prescription)`.

## Measurements

- **TS7056**: 0 in every spec build of this round: origin/main
`5049a3cf`, the pre-remainder-5 head `406335af` and the head `777b5097`.
- **Remainder 5's cost** (`406335af` to `777b5097`): the `view.zod`
chunk grows 495,664 to 498,393 B (+2,729, the same for `.d.mts`); the
`page.zod` chunk moves -3 B; every other declaration file is
byte-identical; all 128 files +5,452 B.
- **The whole PR** (`5049a3cf` to `777b5097`):

| module | base | head | delta |
|:--|--:|--:|--:|
| `view.zod` chunk (`.d.ts`, and the same for `.d.mts`) | 500,191 |
498,393 | -1,798 |
| `page.zod` chunk (report and assembled views) | 306,012 | 311,003 |
+4,991 |
| `index.d.ts` | 3,277,798 | 3,283,480 | +5,682 |
| `system/index.d.ts` | 3,644,976 | 3,647,050 | +2,074 |
| `ui/index.d.ts` | 524,900 | 524,978 | +78 |
| all 128 `.d.ts` / `.d.mts` files | 30,888,091 | 30,910,145 | +22,054
(+0.071%) |

- Item 1's in-memory emit figures (+3,542 B of `report.zod`) are the
previous run's and were not re-measured; the chunk table above
supersedes them.

## Reverse verification (on disk, from committed state, via
`scripts/ablation-replace.mjs`)

All five legs re-run at `777b5097`, from committed state, through
`scripts/ablation-replace.mjs` in wrap mode; each pin file compiled
under `tsconfig.test.json`'s options:

| leg | reverted to | pin file | result |
|:--|:--|:--|:--|
| item 1 | `JoinedReportBlockSchema: z.ZodTypeAny` |
`joined-report-block-type.test.ts` | 7 x TS2578, 2 x TS18046 |
| item 2 | `config: z.ZodTypeAny` in `viewItemArmShape` |
`view-item-config-type.test.ts` | 8 x TS2578, 1 x TS18046 |
| item 3 | non-generic `flattenedViewOverlayFields` |
`view-overlay-viewkind-type.test.ts` | 11 x TS2578 |
| item 4 | the list overlay's `type` keeping the list default |
`view-overlay-viewkind-type.test.ts` | 1 x TS2322 |
| remainder 5 | the cast of the shape to a record of `z.ZodTypeAny` |
`view-overlay-viewkind-type.test.ts` | 6 x TS2578, 4 x TS2322 |

Every leg: the tool reports the mutation landed and the restore proven
(blob after restore equals the HEAD blob, `git diff HEAD` empty), and a
second check re-reads `git hash-object`. Control, no mutation: the three
pin files compile with 0 diagnostics; `git status --porcelain` empty.

## Tests and gates, at `777b5097`

At `777b5097`: spec build exit 0 (TS7056 x0); spec typecheck exit 0
(check:test-typecheck OK, 53 files / 253 errors / 140 signatures); spec
check:generated exit 0; spec vitest local 560 files, 16,493 passed, 1
todo; spec test:repo 34 files, 620 passed; consumer typechecks exit 0
for lint, metadata, metadata-protocol and objectql after building their
12-package closure (rest NOT MEASURED locally, 26-package closure).
dispatch-gates derived 86: 83 exit 0, check-empty-changeset exit 1 (the
deliberate correction), 2 NOT MEASURED (check:dual-build-cjs-loads,
check:type-check-debt, exit 3); `--ran`: 86 derived, 84 run, 2
NOT-MEASURED, 0 UNRUN. Lint narrowed and proven: 7 changed `.ts` files,
0 errors, 0 warnings; the 5 other changed files are outside eslint's
configuration; no type-aware linting (`eslint.config.mjs`:327).

## Consumer census

- **This repo.** Outside `packages/spec`, the code that imports any
narrowed name is:
  - `defineReport` in the two example report files above;
  - `Report` in `qa/downstream-contract/src/pipeline.report.ts`;
- `ReportSchema` in one platform-objects test and one
downstream-contract test;
  - `ViewMetadataSchema` in two metadata-protocol tests;
- `AssembledViewArtifactSchema` in objectql's `engine.ts` and one
objectql test (its own static type does not move).
- Nothing outside spec names `viewItemArmShape` or
`flattenedViewOverlayFields`, which are not exported. All consumer
typechecks above are green.
- **objectui at the pin `f8a9d0fb`: one break, by design, for the seat
to carry.**
`packages/types/src/__tests__/report-chart-query-spec-parity.test.ts`:725
is the inverted tripwire `true satisfies IsUnknown` of the spec's
`JoinedReportBlock`. Compiled with that file's own two type definitions
against this head's dist, it gives TS1360 ("Type 'true' does not satisfy
the expected type 'false'"); line 726 (`IsAny`) still holds.
- Its docblock says this failure is the instruction to re-run the triage
and burn the local interface down, together with the ledger entry at
`scripts/check-spec-symbol-derivation.mjs`:636.
- It reds objectui's own `type-check` (`tsc -p tsconfig.test.json`) on
its next spec bump. It does not red this repo's Console Pin Gate, which
builds objectui without `__tests__`.
- **objectui's other hits.** Five other objectui files reference
`ViewItemSchema` / `ViewMetadataSchema` in code; every other census hit
is a comment or objectui's own local `JoinedReportBlock` interface. From
reading them, not from compiling them: they read `.success` / `.error`
of a `safeParse`, or cast the schema `as unknown as` a local interface.
None reads a typed `config` off a parse result.
- **Remainder 5 at objectui's pin `f8a9d0fb`.** Six calls in four test
files parse a body with `ViewMetadataSchema`, and none reads `.data` off
the verdict, so the now-typed list-overlay `type` / `columns` reach no
objectui read (from reading, not compiling).

## What stays on objectstack-ai#19920

A compiler-API census of the 2,337 non-generic exported aliases of
`packages/spec/src`, excluding tests, with a control that must read lit:
6 aliases resolve to `unknown` before and after this PR, and none
belongs to this family (`FlowValueSlot`, `AssignmentValue` and their
`Parsed`, value slots; `GetPublishedMetaItemResponse` and its `Parsed`,
opaque by ruling). At top-level-key level it counts 210 keys typed
`unknown` before remainder 5 and 202 after; the 8 that left are
remainder 5. Three sites of the family remain, outside this PR's
surface:
- `ApiError.code` (input): `ErrorCode` is cast to a one-argument
`z.ZodType`, whose input defaults to `unknown`, and `makeApiErrorSchema`
repeats the cast. `{ code: 42, message: 'x' }` compiles as `ApiError`
while `ApiErrorSchema` refuses it at `code`.
- `ViewFilterRule.operator` (input): a `z.preprocess`, whose input is
`unknown` (the cause `InlineAction` had). `{ field: 'status', operator:
42 }` compiles while the door refuses it.
- The list overlay's `options` bag: `listViewKindBlocks()` returns a
record of `z.ZodTypeAny`, so the bag is typed as a string-keyed record
of `unknown`. `options: { foo: 1, kanban: 42 }` compiles as
`ViewMetadata` while the door refuses it. The census does not scan
nested keys; a probe found this one.

## Acceptance notes

- **The pin count holds at 783** (`check:spec-parsed-alias`: 1,442 bare
`z.input` aliases, 783 pinned isomorphic, 659 paired).
- **objectui tripwire, carried by the seat.**
`packages/types/src/__tests__/report-chart-query-spec-parity.test.ts` at
objectui pin `f8a9d0fb` asserts that `JoinedReportBlock` is `unknown`
(line 725). It goes red at the next objectui spec-pin bump, and its
docblock says that failure is the instruction to re-run the triage and
burn down the local interface. Carrier: the next objectui spec-pin bump.
It cannot red this repo: the Console Pin Gate builds objectui without
its `__tests__`.
- **Fence held.** `FormViewSchema.layout` (objectstack-ai#20221),
`ListViewShapeSchema.tabs` and the view container's body `name` (objectstack-ai#20301)
are untouched. `origin/main` `5049a3cf` was merged once, at `1161545d`,
with no conflict; commits that landed on `main` later touch none of this
PR's files.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…ive' takes every branch (objectstack-ai#15429) (objectstack-ai#20344)

Fixes objectstack-ai#15429
Clause-②: yes

Carries the maintainer's ruling `5793803317` (「跟主流对齐」, 2026-09-23) as
ONE change, routed to `domain:spec` by `5860007474` and dispatched by
the `domain:spec` seat 4 PM (`session_01CiCTczDo7tGhafXjf61dUJ`, claim
`5860277199`). Branch base `10ea9eb2e`; `origin/main` (`a78f731ad`)
merged through `os-regen-merge.sh`; every reading below is at head
`e92edee5b` unless it says otherwise; the patch commit `27a1a4598` (the
seat's answer A in `5861311629`) and the ruling C round (`5863827385`,
claim `5864486967`, head `18cbf4e2`) carry their own readings where
stated. _(Body revised by the seat at 2026-09-28T01:34Z from the
patch-round report `5861745226`.)_

Coordination card: **objectstack-ai/objectui#10750** (the designer
offers `mode`). Nothing is written in objectui here.

## What changes

1. **Exclusive by default (ruling item 1).**
`AutomationEngine.traverseNext`: on a `decision` node the conditioned
out-edges are evaluated in the order the flow's `edges` array declares
them and the FIRST one whose condition holds is the branch; its later
siblings are not evaluated and record the same `skipped` step a closed
gate does (`skippedBy` names the gate and the edge). `isDefault` is
unchanged: it runs when no conditioned sibling did. Scoped to
`decision`: conditioned out-edges of any other node type keep the
every-true-edge traversal (the census below found none).
2. **Explicit inclusive (item 2).** `config.mode: 'inclusive'` takes
every out-edge whose condition holds, one successor at a time (never
`Promise.all`; the pin's positive control proves the instrument can see
interleaving).
3. **Registration door (acceptance `5857171841`).** `registerFlow`
parses every decision's config through the spec's `DecisionConfigSchema`
and refuses the flow on any issue rooted at `mode` — the refinement
(`mode` beside a non-empty `conditions` list, either member) and the
value refusal — with the schema's own sentence at `node 'x' (decision)
at config.mode`, inside ADR-0031 regions too. Judged on `mode` alone,
deliberately: the same parse also refuses an undeclared key, but that
strictness binds at authoring by the standing decision in the module
header of `schemaless-node-config.zod.ts`, and refusing an inert extra
key at boot would be a second behaviour change riding a ruling that
ordered one. Measured reach of the alternative: zero decision nodes in
either corpus carry a key other than `conditions`, so promoting it later
is cheap.
4. **`os validate` door.** `@objectstack/lint` gains
`flow-decision-mode-invalid` (gating; the finding IS the schema's issue
message, so both doors say one sentence) and
`flow-decision-inclusive-overlap` (advisory, ruling item 4: `mode:
'inclusive'` with two or more conditioned out-edges; beside
`flow-decision-unconditional-branch`, which is about an out-edge nothing
gates).
5. **Migration (item 3).** ADR-0087 D2 conversion
`flow-decision-mode-inclusive-explicit` (protocol 18): a decision with
no `conditions` list, no `mode`, and two or more conditioned out-edges
(a `fault` edge is error routing; a blank condition is none; regions
walked through the shared slot table) gets `mode: 'inclusive'` written,
with a notice naming the count. One conditioned edge plus a default is
left alone; an authored `mode` is left alone (idempotent by
construction). No inference over the conditions, per the ruling. Paired
D3 entry `flow-decision-edge-branching-first-match` names the D2 id as a
whole word and carries the three-way judgment the diff asks for.
`MIGRATIONS_BY_MAJOR[18]` wires the id and its rationale grows a
sentence.
6. **Rewrites (item 5).**
`decision-overlapping-edge-conditions.pin.test.ts` is the contract pin
now, per its own header. `flows.mdx`, the `DecisionConfigSchema`
docblock and `mode` describe, the module header (the declared-ahead
sites of `5852576993` item 2), `logic-nodes.ts`, and `engine.ts`'s
traversal comment all describe both modes; the reference mdx is
regenerated.

## Ruling C (`5863827385`): stored rows take the new meaning — listed,
not rewritten

The ruling, verbatim:

> **Ruled: C.** Ruling `5793803317` item 3 (「保证已上线的流程行为不变」) is narrowed
to the surfaces that can carry it: authored sources (`os migrate meta
--from 17`) and built artifacts. Stored rows (`sys_metadata`) take the
new meaning on upgrade — a decision node with no `config.conditions`, no
`mode` and two or more conditioned out-edges evaluates first-match — and
the changeset and the upgrade guide state that as BREAKING, naming the
shape and the one-line fix (`mode: 'inclusive'`) for a node that meant
every branch. No stored-row rewrite, no cutoff, no read-path completion.
A (write-explicit on save plus read-path completion of a missing `mode`
to `inclusive`) and B (an operator-supplied cutoff) are not taken.
>
> Execution parameters (ruled in the same stroke):
> - PR objectstack-ai#20344 (draft, `6bc84ba59`, CI green) lands after the at-tier
contract review (Clause-② yes), with these edits in its next round: the
D3 entry's and the changeset's 「judgment owed」 sentence replaced by this
ruling; a BREAKING paragraph naming the stored-row shape and the `mode:
'inclusive'` fix; `os migrate meta --stored` lists — report only, no
`--apply` effect — every stored decision node with two or more
conditioned out-edges and no `mode`, so an operator can review
candidates before and after the upgrade. ADR-0087 needs no addendum: the
artifact-door section's premise holds for sources and artifacts, and the
stored seam is stated in the changeset.
> - objectui#10750 (the designer writes `mode` explicitly on save)
proceeds on its own card; it is the same question's other end and is
wanted under this letter too.
> - Pins: a stored decision node lacking `mode` with overlapping
conditions evaluates first-match after upgrade; the `--stored` report
lists it and changes nothing; a source flow migrated with `--from 17`
carries explicit `mode: 'inclusive'` and still takes every branch.

What this round did: (a) prose — the D3 entry
`flow-decision-edge-branching-first-match` (reason tail and acceptance
sentence), the D2 docblock, step18's rationale and the changeset now
state BREAKING for a stored decision with no `config.conditions`, no
`mode` and two or more conditioned out-edges, the one-line fix `mode:
'inclusive'`, and the listing; the upgrade guide renders the D3 entry
(reason = Why not automatic, acceptanceCriteria = Done when) when
protocol 18 is cut; `DecisionConfigSchema.mode`'s describe/docblock and
the traversal comment say 'authored sources'; flows.mdx gains a
stored-flow callout and cli.mdx a --stored paragraph; (b) listing —
`StoredMigrationReport.decisionModeReview` (`StoredDecisionModeReview`:
row id, flow, org, package, state, node id, label, path) in
`@objectstack/metadata-protocol`, filled for every flow row on preview
and apply alike by `collectDecisionModeReview`, which runs the D2
entry's own `apply` over the stored body and discards the result (one
predicate; no engine needed; throws if the entry leaves the registry);
it moves no outcome, count, exit code or write;
`formatStoredMigrationReport` prints it with the fix, and the CLI and
`POST /meta/_migrate-stored` carry it unchanged, so `meta.ts` is not
edited; (c) the ruling's three pins, named below.

## PM mechanism assumptions, measured

**1. Where the traversal lives — held.** Both sites relocated by
content: the conditional loop under the old 「evaluate sequentially
(mutually exclusive)」 comment in `engine.ts` (`traverseNext`), and the
`config.conditions` first-match in `builtin/logic-nodes.ts` (untouched,
still label-narrowing). Declaration order is `flow.edges` array order:
`traverseNext` filters that array in place; `FlowSchema.parse` (region
transform included) and every conversion walker are copy-on-write maps
that never reorder; `normalizeStackInput` normalizes map-form
collections at the stack level and never touches a flow's `edges`;
`canonicalizeStoredFlow` runs those same two. Grep for any edge re-sort
across `packages/*/src` and `packages/*/*/src` (`edges.sort`,
`sortEdges`, `.edges.slice().sort`, `localeCompare` on edges): 0 hits.
NOT MEASURED: the Studio designer's own serialization order at save time
— objectui is not checked out in this container; server side,
`saveMetaItem` canonicalizes without reordering.

**2. The migration predicate — census.** Corpus: this repository's
examples at `10ea9eb2e` and `objectstack-ai/hotcrm` at `2f7b2326`
(read-only), every flow module loaded and every graph walked including
regions; platform packages ship no decision node (grep over
`packages/platform-objects`, `plugins`, `services`: only the executor
and the README).

| corpus | flows | decisions | rewritten (no list, ≥ 2 conditioned, no
`mode`) | left alone | non-decision nodes with a conditioned out-edge |
|:--|--:|--:|--:|--:|--:|
| examples (app-crm, app-todo, app-showcase) | 35 | 5 | 4 | 1
(`crm_convert_lead_wizard.check_converted`: 1 conditioned + `isDefault`)
| 0 |
| hotcrm | 13 | 25 | 13 (incl. `lead_conversion.decision_duplicate`, the
objectstack-ai#1555 node, now three conditioned edges) | 12 (single-conditioned
guards, no default) | 0 |

Every one of the 17 positives is a hand-written partition by inspection
(a predicate beside its negation, `>` beside `<=`, `has()` beside
`!has()`, hotcrm's `CASE_HAS_OWNER` beside its exact complement,
`memberSource != "contacts"` beside `== "contacts"`), so first-match
changes none of their runs; the conversion still writes the key onto all
17, as ruled, and the D3 entry tells the author to delete it there. No
decision in either corpus carries a config key other than `conditions`.
`examples/**` is outside this claim's surface and is not edited: the
four in-tree positives partition, so nothing changes at boot.

**3. Stored flows — FAILED, and adapted.** The assumption was that the
conversion replays at rehydration like the other step-18 entries. It
cannot: this is a DEFAULT FLIP (the old shape still parses and now means
exclusive), and the flow rehydration seam serves post-flip authored
bodies too — `canonicalizeStoredFlow` is reached by the boot pull for
code-shipped flows, by `POST /automation`, by `saveMetaItem` (every
Studio save) and by `duplicatePackage`, all through one two-argument
signature, none dated. Replaying there would rewrite every NEW exclusive
decision into an inclusive one at registration and persist it at save,
and the ruled default would be unobservable. The registry's own doctrine
for this class (`excludeConversionIds`, the artifact door's
`DEFAULT_FLIPS_NOT_REPLAYED_HERE` for `app-hidden-to-unpublished` on
objectstack-ai#17885, the WITHDRAWN `field-required-notnull-explicit` note) says a
seam that cannot state 「this body predates the flip」 refuses the entry
by id. So:

- the entry is `retiredFromLoadPath: true` (no authoring window —
「不留过渡窗口」) and replays where the operator asserts the source's age: `os
migrate meta --from 17` (the D3 chain), pinned both ways;
- `canonicalizeStoredFlow` refuses it by id
(`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`, reason at the call site),
pinned on `parsed`, `storable` and notices, with the chain as the firing
control;
- **Stored `sys_metadata` flows take the new meaning — ruled C
(`5863827385`).** A decision saved before this release with two or more
conditioned out-edges and no `mode` runs first-match after the upgrade;
no pass rewrites it (no stored-row migration, no cutoff, no read-path
completion). BREAKING, stated in the changeset and the D3 entry with the
one-line fix `mode: 'inclusive'`; `os migrate meta --stored` lists every
such node, report only.
- **The artifact-ingestion door refuses it too (patch commit
`27a1a4598`, the seat's answer A in `5861311629`).**
`packages/metadata-core/src/artifact-forward-conversion.ts` lists
`flow-decision-mode-inclusive-explicit` in
`DEFAULT_FLIPS_NOT_REPLAYED_HERE` beside the `app-hidden-to-unpublished`
precedent, with its reason: the door's trigger is the artifact's
declared `engines.protocol` floor, `^17.0.0` is what
`create-objectstack` stamps, so an app scaffolded today against the
exclusive contract lands inside the window and would otherwise be handed
an inclusive gateway it never asked for. The door pin has four legs
(subject, the strict parse the door feeds, negative, firing control),
and the engine seam's and the entry's docblocks now cite the door
precisely.

**4. Serial state — moved, merged.** `origin/main` gained objectstack-ai#20286
(`view-overlay-owner-hidden-removed`) on the same registry lines;
`os-regen-merge.sh` merged it (both entries kept in landing order in
`conversions/registry.ts` and in `MIGRATIONS_BY_MAJOR[18]`, rationale
concatenated), `gen:migration-registry` regenerated to an identical
file, `check:generated` found every artifact current, and every sibling
symbol was asserted present on both sides by exact-name grep
(`viewOverlayOwnerHiddenRemoved` 3/3,
`view-overlay-owner-hidden-removed` 9/9, `view.zod.ts` `retiredKey`
21/21).

**5. Ruling C round (dispatch assumptions).** (1) The report type is
metadata-protocol's, not a spec contract type (`api/protocol.zod.ts`,
ruling 2C note), so the widening is `StoredMigrationReport` +
`StoredDecisionModeReview` there, covered by `Clause-②: yes`; the
renderer is also metadata-protocol's, so `meta.ts` needed no edit. (2)
The listing reads the stored body directly, with no engine, through the
D2 entry's `apply` by id; a flow row skipped for want of an engine still
lists. (3) `origin/main` `15bf186f5` (32 commits) merged through
`os-regen-merge.sh` as `df3f6a00`: two hand-written conflicts (both
registries; main's `form-layout-inline-grid-to-vertical`,
`currency-config-precision-removed`, `permission-rls-tags-removed` kept
ahead of ours), the reference mdx regenerated (`62771d8e`),
`gen:migration-registry` a no-op on the merged entries, sibling ids and
symbols asserted present 2/2 and 2/2. (4) objectstack-ai#20316: branch
`claude/issue-20316-flow-node-config-build-doors` exists, no PR; overlap
with this PR is `conversions.test.ts` and `migrations/registry.ts` only;
nothing here touches `registerFlow`.

## Surface

22 files, +2157 / −205 (2362 changed lines against merge base
`15bf186f5`, under the 5000 human-merge threshold). Two files entered by
the claim's surface amendment (`5861311629`):
`packages/metadata-core/src/artifact-forward-conversion.ts` (only the
`DEFAULT_FLIPS_NOT_REPLAYED_HERE` array and its reason docblock) and
`packages/metadata-core/src/artifact-forward-conversion.test.ts` (the
door pin). Two files the claim did not spell are recorded there as
covered: `packages/spec/src/conversions/registry.ts` (where every D2
conversion lives) and `packages/lint/src/index.ts` (the two rule-id
exports, required by `rule-id-barrel-exports.test.ts`). ⛔ Not touched:
`flow-node-expression-paths.ts`, `examples/**`, `packages/cli/**`,
`packages/runtime/**`, `packages/rest/**`,
`packages/spec/src/contracts/**`, objectui. The ruling C round adds
`packages/metadata-protocol/src/{stored-migration,protocol,index}.ts`,
`protocol.stored-migration.test.ts` and
`content/docs/deployment/cli.mdx`. ⛔ Still not touched:
`packages/cli/**`, `packages/runtime/**`, `packages/rest/**`,
`packages/spec/src/contracts/**`, `examples/**`, `docs/adr/**`,
objectui.

## Pins that carry weight, and the ablations

`decision-overlapping-edge-conditions.pin.test.ts` (21 tests): two
overlapping true edges → exactly one runs, the first declared, the
sibling records `skipped`; declaration order decides (the same
predicates reversed take the other branch); `mode: 'inclusive'` → both
run nested, no skipped step; none true → `isDefault` runs in both modes;
a true edge beside a default passes the default over in both modes; a
`conditions` list still narrows by label; registration refuses the pair
(either member) and a bad value with the spec sentence, inside a loop
body too, and the flow is never armed; the four controls register; the
rehydration seam leaves the two-branch shape unrewritten while
`applyMetaMigrations(stack, 17, 18)` rewrites it; a non-decision node
keeps every-true-edge. `conversions.test.ts`: the fixture pair (2
notices) plus the predicate's edges, region reach, idempotence, the
authoring funnel's silence, and the seam refusal with its firing
control. `lint-flow-patterns.test.ts`: both rules, gating vs advisory,
controls, regions, no double report through rule (2).
`migrations.test.ts`'s census pin sees the D3 entry naming the D2 id.
`artifact-forward-conversion.test.ts` (`27a1a4598`): a `^17.0.0`-floor
artifact carrying a two-branch decision passes the door with no `mode`
written, no notice for the id and the same reference back; the strict
parse the door feeds receives no `mode`; `^99.0.0` shuts the window; and
the same fixture through `applyConversions` with `includeRetired: true`
and no refusal comes back `{ mode: inclusive }` with the entry's notice
(firing control). Ruling C (`5863827385`):
`decision-overlapping-edge-conditions.pin.test.ts` — a decision as a
pre-18 row stores it (JSON text, no `mode`, the hotcrm#1555 pair) passes
`canonicalizeStoredFlow` with no `mode` written, registers and runs
FIRST-MATCH (second branch `skipped` on its edge); the same body through
`applyMetaMigrations(stack, 17, 18)` carries `mode: inclusive` and runs
EVERY branch, nested, no skipped step (22 tests).
`protocol.stored-migration.test.ts` — the stored node is listed with
row, flow, node, label and path while the row stays canonical and clean;
`--apply` changes no byte and writes no history; a row rewritten for
another conversion persists no `mode`; no engine still lists; region
path; controls (either `mode`, one edge plus default, `conditions` list,
`fault` edge); the list equals the `--from 17` chain's write paths; the
renderer prints it beside the on-protocol verdict.

Both ablations ran from committed state through
`scripts/ablation-replace.mjs` (anchor hit 1→0, marker 0→1, blob hashes
printed), the reading was taken, and restore was `git checkout HEAD --
ABS_PATH` under a trap, proven by `git diff HEAD` clean and `git
hash-object` equal to the HEAD blob. No dist leg was owed: both suites
resolve their subject through `src` (`../engine.js` inside
service-automation; `./registry.js` inside spec).

- traversal: `if (exclusive && anyConditionMet)` → `if (false && …)`.
Blob `a60861d3985717a743cb32c16d9e3ba925dee3c7` →
`f0e25d39262ae22b38ef67b5affbba494c0023bf`. Ablated run: **6 failed**
(exactly the exclusivity, skipped-step, declaration-order,
written-exclusive, default-passed-over and seam-runs-exclusive pins), 15
passed (the controls, inclusive, default and registration pins).
Restored: `a60861d3…` on disk and at HEAD.
- predicate: `MIN_CONDITIONED_EDGES = 2` → `3`. Blob
`fd1a7902d480b791e7f53116eb38c97ad268fb78` →
`a03f5cbdf9f742aabf42e8400a1fc5df50b50d1b`. Ablated run: **5 failed**
(the fixture pair, the wiring pin, the two-edge rewrite, the left-alone
pin, the seam-refusal firing control), 216 passed. Restored: `fd1a7902…`
on disk and at HEAD.
- artifact door (`27a1a4598`): the id removed from
`DEFAULT_FLIPS_NOT_REPLAYED_HERE` (anchor 1→0, marker 0→1). Blob
`16742f49e72eaa98214eca097b9b14cef03e8809` →
`26220cf59c92d7b4daf75a74b17e5a076156503c`. Ablated run: **2 failed**
(the subject leg — `mode` written — and the strict-parse leg), 27 passed
(the firing control and the negative stayed green). Restored:
`16742f49…` on disk and at HEAD.
- stored listing (`18cbf4e2`): `protocol.ts` `for (const node of
collectDecisionModeReview(body)) {` emptied (`.slice(0, 0)`), blob
`711fded5ddea7d37b4f6d2a52f7b7a6a80db8081` →
`ce0c296d5134527c0634c1932ec88b59c6285dbc`; ablated run **5 failed** |
34 passed (the five listing pins; region, controls, parity and the
nothing-to-review control green); restored `711fded5…` on disk and at
HEAD.
- stored-row seam (`18cbf4e2`): `engine.ts` `excludeConversionIds:
CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION,` removed (a read-path
completion), blob `daac6de07304ae4051f1681ab4311c447a8ad3a9` →
`a3555237ccca29ff4ad888bd009cc97be4007ae8`; ablated run **7 failed** |
15 passed (the ruling C stored-row pin, both seam pins, four
exclusive-traversal pins; the `--from 17` source pin, inclusive,
default, registration and boundary green); restored `daac6de0…` on disk
and at HEAD.

## Tests, at `e92edee5b`, every exit captured after a redirect

- `pnpm --filter @objectstack/spec test` → exit 0: `Test Files 554
passed (554) · Tests 16366 passed | 1 todo`.
- `pnpm --filter @objectstack/service-automation exec vitest run
--maxWorkers=2` → exit 0: `Test Files 147 passed (147) · Tests 1782
passed (1782)`.
- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` →
exit 0: `Test Files 111 passed (111) · Tests 4313 passed (4313)`.
- `typecheck` for the same three packages → exit 0 each
(`check:test-typecheck` OK on each test layer).
- Importers of `DecisionConfigSchema` outside these packages:
`metadata-protocol`'s JSON-projection walk (a refinement projects
byte-identically) and `config-expression-ledger.test.ts` (in the
service-automation run above); no other importer of the traversal exists
(`registerFlow` callers in `runtime` and `plugin.ts` are unchanged call
sites).
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 114 commands on this branch; all 114
ran on `e92edee5b` with exit 0 (`check:dual-build-cjs-loads` and
`check:type-check-debt` first answered exit 3, PREREQUISITE NOT MET,
until the whole `packages/*` closure was built — 71/71 — then 0);
`--ran` reconciliation: `114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN`.
`check:generated` on the merged tree: every artifact current after
`gen:docs`. `spec-changes.json` and the upgrade guide render no
protocol-18 id yet (control: `report-joined-chart-removed` 0 hits too),
so their green is genuine, not a missed regeneration.
- Patch commit `27a1a4598`, readings at that head: `pnpm --filter
@objectstack/metadata-core exec vitest run --maxWorkers=2` → exit 0:
`Test Files 16 passed (16) · Tests 289 passed (289)`; metadata-core
`typecheck` → exit 0. Re-run because the diff reaches them (docblock
edits in `engine.ts` and `conversions/registry.ts`): service-automation
`Test Files 147 passed (147) · Tests 1782 passed (1782)`, spec `Test
Files 554 passed (554) · Tests 16366 passed | 1 todo`, both typechecks
exit 0; lint is not reached and was not re-run. Gates: the same 114
derived commands (0 added, 0 dropped), all exit 0 on `27a1a4598`;
`--ran`: `114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN`.
`check:type-check-debt` first refused (exit 3) because metadata-core's
cache-restored `dist/` was older than its source after the ablation's
restore rewrote the file; a direct `pnpm --filter
@objectstack/metadata-core build`, as the gate prescribes, and a re-run
gave 0.
- Ruling C round, at `18cbf4e2`: spec `test` `557 passed (557) · 16508
passed | 1 todo`; spec `test:repo` `35 passed (35) · 634 passed (634)`;
metadata-protocol `189 passed | 3 skipped (192) · 2745 passed | 19
skipped (2764)`; service-automation `147 passed (147) · 1783 passed
(1783)`; metadata-core `16 passed (16) · 289 passed (289)`; lint `113
passed (113) · 4713 passed (4713)`; cli unit `230 passed` plus the two
published-subpath pins `2 passed (2) · 29 passed (29)` after a
post-merge `pnpm install` (prerequisite, not a red); typecheck exit 0
for all six. Gates: 117 derived, 117 run, `--ran`: `117 derived, 117
run, 0 NOT-MEASURED, 0 UNRUN` (three first answered exit 3 PREREQUISITE
NOT MET until the full `./packages/*` closure and `client-react` /
`metadata-protocol` were built). Narrowed eslint over the PR's 18
changed `.ts` files: 0 errors, 0 warnings (no type-aware linting in
`eslint.config.mjs`). CI on `18cbf4e2`: 33 success, 2 skipped, all seven
required contexts success.

## Changeset grade, measured at landing

npm `latest` `@objectstack/spec` is `17.4.0` (`npm view`, re-measured
2026-09-28), whose published `DecisionConfig.json` declares `conditions`
only; `.changeset/19867-decision-config-mode.md` and
`.changeset/20168-…md` are still unconsumed, so `mode` is unreleased and
reaches its first release with the traversal that reads it and the
conversion that writes it. `Clause-②: yes` per the ruling's item 2 (the
D2/D3 entries and the two lint rules widen the published surface;
nothing published narrows). `minor` for `@objectstack/spec`,
`@objectstack/service-automation` and `@objectstack/lint`, with the
BREAKING banner, the FROM → TO block and the disposition marker
`registered flow-decision-mode-inclusive-explicit` (the changeset file
carries it in the gate's own form). `@objectstack/metadata-protocol`
`minor` (the report widens) and `@objectstack/metadata-core` `patch`
(the artifact door's refusal) join the bump list; the changeset carries
the ruling C BREAKING section.

## Acceptance notes

- **Landed on this PR (`27a1a4598`)**: the artifact door's refusal of
`flow-decision-mode-inclusive-explicit`, per the seat's answer A
(`5861311629`).
- **The stored-row half is ruled C (`5863827385`) and landed in this
round**: stored rows take the first-match meaning (BREAKING, stated with
the fix), `os migrate meta --stored` lists them report-only, and the
three pins hold it. objectui#10750 (the designer writes `mode` on save)
proceeds on its own card and is not this PR.
- `origin/main` moved two commits after this round's merge (`0d7ed5a3`
regenerates `packages/spec/src/migrations/registry.ts`); the landing lap
merges it through `os-regen-merge.sh`.
- `skills/objectstack-automation/SKILL.md` line 65 (「routed by edge
`condition` predicates」) stays true and does not mention `mode`;
governed surface, not touched.
- The REST door answers a registration refusal as `VALIDATION_ERROR` 400
through `flowDefinitionRefusal` (unchanged code path); not pinned here,
the runtime package is outside this surface.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…m reconciliation gate (objectstack-ai#19333, item 2) (objectstack-ai#20520)

Fixes objectstack-ai#19333
Clause-②: no

## What this does

Item 2 of objectstack-ai#19333, its last remaining item (landing record 5860224378):
the top-level `zodOnly` direction of the metadata-form reconciliation
gate,
`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`, is
now wired.

Before this PR, the per-type top level asserted only form-only and
retired. So "the schema declares this key and no form row offers it" had
no reader at the root, while the nested lists already had one. Now every
object-rooted type reconciles its root the same way:

- **`reconcileRoot`** is the nested predicate's `zodOnly` at
`ROOT_PATH`, built from the same `resolveCoordinate` / `offerableKeysAt`
/ `omittedAt` / `isSubset` helpers the resolve test uses. The ADR-0010
overlay and `retiredKey()` tombstones need no row.
- **A new `it.each(TOP_LEVEL_TYPES)`** fails a type by name when a key
the author may write at the top level is neither offered by the form nor
excused by a root ledger row. Failure text: `TYPE.(root): accepted by
the Zod but unauthorable in the form — offer it, or add a root ledger
entry that records why it is not offered`, with the offending keys in
the diff.
- **`view` is deferred by name, with its reason, in
`TOP_LEVEL_DEFERRED`.** Its root is a union, and it is reconciled per
arm once an arm form exists (the objectstack-ai#19330 ruling, letter A). A pin holds
the deferred set equal to the union-rooted registered types, so the map
cannot excuse an object-rooted type, and a new union-rooted type cannot
slip into the direction unexcused. The direction judges 16 of 17 types.
- **Synthetic positive and negative controls** drive the same
`reconcileRoot` over the file's existing root-coordinate fixture:
  - An unoffered, unexcused key is named.
  - A root `omit` or root `subset` row excuses it.
  - A row at a nested path, or for another type, excuses nothing.
- **Comments made true again.** The two "the top-level zod-only
direction stays unwired" passages are rewritten. The present-tense "132
of the 274" readings now read as the historical census they are. That
was the carrier note left for whoever wired this item.

**The reason ledger is unchanged:** 37 rows, 26 at the root. No schema,
form, `describe()`, liveness row or generated artefact changes. One
file, +114 / −14.

## Verification record

### 1. The residue, re-derived first on `main` `4a1df19656`, with the
gate's own helper block

- **Instrument.** The gate file's bytes 0 up to the first line-start
`describe(` (0..48202, sha256 `06ccb54e052ad2b2…`), copied verbatim into
a throwaway probe beside it. The prefix was checked byte-identical, and
the probe was deleted after the run.
- Identity: the same slicer at `736c63a85` reproduces sha256
`7b97432d8408f12e…`, the instrument recorded in 5825062779.
- Census per type: `resolveCoordinate(form, root, ROOT_PATH)`,
`authorableKeysOf`, `offerableKeysAt(…, ROOT_PATH)`, `omittedAt(LEDGER,
type, ROOT_PATH)` and `isSubset`.
- Run under `os-verify-lock`: VERDICT command-exit 0, 2 files / 58
tests.
- **Controls, asserted inside the probe:**
- LIT: `name` is offered by 17 of 17 forms and declared by 17 of 17
schemas.
- DARK: a fabricated key is offered by 0, declared by 0, and is in the
residue 0 times.
- Residue LIT: dropping the one `field.format` row from a ledger copy
surfaces `format`.
  - Residue DARK: with the ledger as it stands, `format` stays out.
- **Reading.**

| top-level keys no form offers | overlay | excused by a root row |
residue | of which object-rooted |
  |---|---|---|---|---|
  | 202 | 132 | 26 | 44 | **0** |

All 44 residue keys are `view`'s, which is union-rooted and outside the
direction (ruling A). ⇒ the claim's branch "it reads 0" holds, and the
direction was wired.
- **Against the previous round** (5859927065 at `096a8dbab`: 230 / 132 /
83, object-rooted 39): the 39 object-rooted keys were resolved by
objectstack-ai#19332's flights. 11 got root rows (root rows 15 → 26); the other 28
left the not-offered set through form rows or the `action.aria`
retirement (230 − 28 = 202). `view` stayed at 44.
- **`app._unpublished`** is not in `FRAMEWORK_FIELDS`, and today's
ledger answers it with its own platform-written root row. The census
counts it as excused, not as residue, so the wiring does not fail on it.
- **Re-read after merging `main` (`9449512a31`):** the gate's new
direction, green at the merged head, IS the same census, at 0
object-rooted residue. `main` has since moved to `9e9bb46417`, touching
no form, registered root schema or registry path.

### 2. Ablation, from the committed state (`47ecd08a9f`), one lock hold
(VERDICT command-exit 0)

Every mutation went through `scripts/ablation-replace.mjs` in WRAP mode:
anchor hit x1 → x0, blob changed, on-disk `grep -c` of the planted and
removed text printed inside the wrapped child.

| leg | mutation | on disk | gate |
|---|---|---|---|
| L1 lit, wired | plant `zzPlanted19333` in `PositionSchema`, no reason
| planted=1, direction=1 | **RED** 1 failed / 75: `position.(root):
accepted by the Zod but unauthorable in the form …` expected `[
'zzPlanted19333' ]` |
| L2 lit, direction removed | same plant, and the new
`it.each(TOP_LEVEL_TYPES)` block deleted | planted=1, direction=0 |
**GREEN** 60 / 60: the gate misses the planted key |
| L3 dark, explained | same plant, plus a root `omit` row recording its
reason | planted=1, row=1 | **GREEN** 76 / 76 |
| L4 lit, reason removed | the `field.format` root row deleted |
formatRow=0 | **RED** 1 failed / 75: `field.(root): …` expected `[
'format' ]` |

- **Restore.** The gate's blob `1aa1b108e284` and `position.zod.ts`'s
blob `989e07cae48e` each equal HEAD, `git diff HEAD` is empty, and `git
status --porcelain` shows 0 lines. The tool's own proof after each leg
and a final script-level hash check agree.
- **No build in the loop:** the gate imports `src` by relative path.
- For contrast: in 5825062779 (ablation 2), deleting a root row left
this gate green. That was the measured meaning of "unwired" then.

### 3. Tests, typecheck, lint, gates

- **Gate at `47ecd08a9f`:** 1 file / 76 tests, VERDICT command-exit 0.
That is 57 before, plus 16 per-type root cases, 1 deferral pin and 2
synthetic controls.
- **Whole-closure build** after the merge: `turbo run build
--concurrency=2 --filter='./packages/*' --filter='./packages/*/*'`, 71
of 71 successful (VERDICT command-exit 0). The tree was clean
afterwards.
- **At `eeb01c7143`** (the merge; the diff vs `main` is this one file):
- `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2`: exit 0, 573 files, 16820 passed + 1 todo.
- `pnpm --filter @objectstack/spec typecheck`: exit 0 (`tsc --noEmit`,
`check:scripts-typecheck`, and `check:test-typecheck` holding 53 files /
251 errors / 138 pinned signatures).
- Coverage counted, not assumed: `tsc --noEmit -p tsconfig.test.json
--listFiles` lists this file (1 hit; control
`src/identity/position.zod.ts` 1 hit) with 0 errors in it. The program's
251 errors equal the pinned count, and its exit 2 is that debt.
- **Lint, narrowed with measurement:** `eslint --no-inline-config
--format json` on the one file gives 1 file, 0 errors, 0 warnings.
- Population: `eslint --print-config` resolves a config for it, so it is
linted, not ignored.
- Invariance: `parserOptions` holds only `ecmaVersion` / `sourceType`
(no `project`, no `projectService`), with 4 rules, none type-aware. So
this edit cannot move any other file's verdict.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `eeb01c7143` derived 78
commands. Each was run with its exit code captured before any pipe, and
all 78 exit 0. `--ran`: `78 derived famil(ies) accounted for — 78 run, 0
NOT-MEASURED (a DERIVED zero …)`.
- **Changeset: none, the change is test-only.** `npm pack --dry-run
--ignore-scripts` of `@objectstack/spec` lists 2028 files with 0
`*.test.ts`. The changed path is absent; the positive control
`src/identity/position.zod.ts` is present. The new symbols
(`reconcileRoot`, `TOP_LEVEL_DEFERRED`) hit 0 files in `dist/`, against
the control `MetadataProtectionFields` in `dist/identity/index.js`. ⇒
`skip-changeset`.

## Acceptance notes

- **An in-flight PR that adds a top-level key to one of the 16
object-rooted schemas without a form row now goes red in the queue.**
That is the design: the fix is an offer, or a root ledger row with its
reason.
- **What "explained" is worth depends on the rows.** The three ruled
root reasons are closed by admission tests (ruling record 5861442317).
The five read reasons admit any root row whose `why` is over 20
characters, which is the same discipline the nested ledger has always
had. The wiring does not change that. It is noted here because
"explained" at the root is now exactly as strong as that discipline.
- **`view`'s 44 residue keys stay recorded, not asserted, until the
first arm form is registered (ruling A).** Among them is `_isOverride`,
the console-stamped wire discriminant: underscore-prefixed, but not in
the ADR-0010 envelope. Whoever registers an arm form meets it.
- **The ledger's `view` block** still says `owner` / `hidden` are no
longer writable at all. The earlier round routed that to PR objectstack-ai#20286; it
is untouched here.
- **objectstack-ai#19188 is the card that named this defect.** It remains open for the
seat's own disposition; its `Blocked-by` lines name this card and
objectstack-ai#19332.

---

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

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

Projects

None yet

2 participants