feat(spec)!: retire the flattened view overlay's owner and hidden keys (ADR-0049) - #20286
objectstack-fleet[bot] merged 13 commits into
Conversation
…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>
…erlay-owner-hidden-retired
…session in this entry Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…erlay-owner-hidden-retired
…erlay-owner-hidden-retired
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: 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 ① Derived judgments(a) Stop valve — HELD, 0 real writers. HotCRM 2f7b2326: 14 view files, 0 files with (b) Refusal — HELD at every door, with one residue class stated under (c). Both overlay members refuse each key with (c) D2 (d) D3 (e) Retired keys under (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 (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 ② Semver level
③ Boundary flagsBlocking: the hide-only residue under (c)/(d). The card's measured shape 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, Implemented-by: VERDICT: FAIL |
…erlay-owner-hidden-retired
…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
…erlay-owner-hidden-retired
…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>
Contract reviewServed-tier: 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, ① Derived judgments(a) Residue text — TRUE at the head for both classes, in all five places. Content-bearing row ( (b) New pins — load-bearing. Strip ablated ( (c) Loosened claimant pin — sound, and it still catches a second record. A fake (d) The #20255 sentence — true at the head: the overlay pair has its D2 in (e) Merges. 24789bb and 2713d1b: commit tree identical to (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 ② Semver levelUnchanged: ③ Boundary flagsBlocking: none 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, Implemented-by: VERDICT: PASS |
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>
…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>
…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>
…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>
Fixes #20230
Clause-②: no (narrowing)
What this does
Retires the flattened view overlay's
ownerandhiddenkeys 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/:namebody with noconfig: members 3 and 4 of theviewunion (VIEW_METADATA_MEMBERS.listOverlay/.formOverlay), built fromflattenedViewOverlayFields()inpackages/spec/src/ui/view.zod.ts. It declared both keys, the write door accepted them, andsaveMetaItemstored 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:{ object, viewKind, hidden: true }) is left identity-only, which the door refuses. It is badged invalid, refused on a whole-row re-save, and reportedfailedbyos migrate meta --stored --applyuntil 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.
owner/hidden.objectui-shapinf8a9d0fbpersistViewPatchtoolbar path,updateView/updateViewConfig/createViewin the data adapter,viewEnvelopesaves, and the twoPublicFormsPagesaves). None writes either key. The toolbar's overlay keys areVIEW_OVERLAY_OWNED_KEYS=rowHeight,sort,hiddenFields,columnState,inlineEdit. The switcher writeslabelandisPinned.persistViewPatchcall sites writing the owned keys; 2updateViewrow-key writesmain6cf5999persistViewPatchcall sites; 2 row-key writespackages/**,examples/**e46218674viewKindat all, and no view-levelhidden/ownerin 10 view files. No framework source writes an overlay body with either key.label:88 times in the same 10 example view files2f7b2326(= its remotemain)hidden/ownerin 14 view files.label:159 times in those filesadd_repowas refused for this session. #20227's census at cloud48d70663recorded 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/.ownerreads on a view inrest-server.ts= 0/0 and inmetadata-manager.ts= 0/0. The 5.hiddenreads inmetadata-protocol/src/protocol.tsare all field-level. Control:.orderis read 2 / 1 / 2 times in the same three files.Dispatch assumptions, measured
view.zod.ts:5284-5285one46218674, andflattenedViewOverlayFields(kind)takes akindargument. Held. Only those two keys move.retiredKey()tombstone applies. Held. Both overlay members.strip(), and az.never()member refuses loudly instead of stripping: the pins below assert issue codeinvalid_typeat path[key], carrying the prescription.view-item-owner-hidden-removedskips any body without aconfigdict, and its own fixture pinned an overlay'shidden: trueas kept. This PR adds a separate entry, disjoint byconfig.view.zod.tsregions were not touched: no edit inFormViewSchema.layout,ViewMetadataParsedordiagnoseViewMetadata.The route
owner: retiredKey(VIEW_ITEM_OWNER_RETIRED)andhidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED)inflattenedViewOverlayFields(). 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.view-overlay-owner-hidden-removed(toMajor: 18,retiredFromLoadPath: true, losslessstripKeys). Scope: the flattened spelling, meaning a body with noconfigand no container slot. It walksviews(stack sources, and every stored row, whichconvertStoredItemreplays before serving or badging) andviewItems(the assembled channel). It does NOT requireviewKind: 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 intoMIGRATIONS_BY_MAJOR[18], and the step rationale is extended.updateViewis a read-merge-write, andbuildPersistedViewBodyre-sends a saved view whole. A stored row served WITHhiddenwould make the next toolbar toggle a 422, so the read path strips first.labelis identity) answers 422, and--applyreportsfailedand leaves the row as stored. A toggle that adds a real key saves.view-overlay-owner-hidden-retired(ruling B on [Decision] 一次退役,要写一条记录还是两条?—— 迁移条目的 D2/D3 约定,两处成文相互矛盾 #17152). It names its conversion by id inreason, 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 onmainand green here. ItsacceptanceCriteriastate 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 (amendment5859181450).RETIRED_KEYS_BY_MAJOR[18]:ui/ViewMetadata:ownerandui/ViewMetadata:hidden. The overlay members are not exported.ui/ViewMetadatais the exported door they are reached through, and it is listed inunemitted-schemas.baseline.json, so these rows are declared, not judged. The retirement test pins them.viewledger walks the container keys only (name,label,object,list,form,listViews,formViews), so a row would be an ORPHAN.check:livenessis green without one.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.jsonand 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).examples/,skills/andcontent/docs/. The tree-scoped pin below holds that.@objectstack/spec: minor,**BREAKING**, FROM → TO, the one-line fix,Clause-②: no (narrowing), ADR-0087 dispositionregistered 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):invalid_type, the path, and the prescription. Theviewdoor (getMetadataTypeSchema('view')) refuses withinvalid_union, the prescription surfaces as the union's message, and the claimed member locates the key. The assembled channel refuses too.isDefault/order/scopeintact and no key grown. The view item door refuses the pair as well, so the family is closed on both doors.defineViewis the container door, not an overlay door.viewKind-less flat row is stripped. TheviewItemschannel 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.view-overlay-owner-hidden-retired, so it is a pointer, never a second record.hidden,owneror 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.label) is refused.isDefault/order/columnStatesave.The ADR-0112 envelope is pinned at the door that produces it.
packages/metadata-protocol/src/protocol.save-union-issues.test.tsadds a describe block over the existing stub-engine harness (no new double). For each key and each family,saveMetaItemrejects withcodeINVALID_METADATAandstatus422, 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:
INVALID_METADATA/ 422, with 0 rows and "only identity fields". The same row plusisDefaultsaves.getMetaItemover a seeded row serves a stored overlay withoutowner/hidden._diagnosticsis 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
viewKindbesidehidden/owner, in all packages.spec/ui/view-item-owner-hidden-retirement.test.tsBOUNDARYview-overlay-owner-hidden-removed, the record key by the view-item entry (asserted as pairs)viewKind+ a retired key); anti-vacuity cases for an overlay (TS, YAML) and a containerspec/conversions/registry.tsview-item fixturehidden: truespec/ui/view-metadata-schema.test.tsa hide PUTaccepted;identity + hiddenacceptedhiddenrefusedspec/ui/view-union-diagnostics.test.tsoverlay.list.aux(withhidden) andput.hiddenACCEPTEDput.owner; a new test asserts those rows are refused BY the tombstone (the prescription,invalid_typeat the key)spec/conversions/view-spelling-walk.test.tsownersurvives conversionownerstripped, with the notice under the overlay entry; every binding key still survivesmetadata-protocol/src/metadata-diagnostics.union-issues.test.ts{hidden, object, viewKind}badgedvalid: truehidden; a live key is badged validVerification
Patch round 1, final head
a05b32f8b, merged withorigin/mainat4e0f72e8d, which carries #20238, #20255 and #20244 (dev report5860055944):--project local, full: 553 files / 16341 tests.migrations.test.ts, with the census pin shown verbosely: 6 / 530.turbo build rest^...: 24/24.check:generated: 15/15 current.check:dual-build-cjs-loads,check:type-check-debt: exit 3, PREREQUISITE NOT MET), 0 UNRUN.e38a8027b): the overlay strip replaced byreturn view→ 6 red (the residue, stored-row,viewKind-less andviewItemspins) / 18 green. The restore was proven by blob == HEAD and an emptygit diff HEAD.The round-0 readings below are at
2a40c104c.Round 0: final head
2a40c104c. That is after mergingorigin/mainat17bd3187, which carried #19920'sview.zod.ts/assembled-views.zod.tstype change. Heavy runs went throughscripts/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.turbo run build --filter='@objectstack/rest^...'(spec + the consumer closure)2a40c104cpnpm --filter @objectstack/spec check:generated2a40c104c--project local, full2a40c104c--project repo,view-item-owner-hidden-retirement.test.ts(tree-scoped pin)2a40c104cview-write-path-identity.test.ts2a40c104ctsc+ scripts +check:test-typecheck), lint, metadata-protocol2a40c104ccbc81c574Reverse verification (a one-shot probe removed by an EXIT trap, verified absent afterwards):
packages/lint/src/zz-issue20230-dts-probe.tstyped{ object, viewKind: 'list', hidden: true }asViewMetadata, against the REBUILT spec.d.ts.@objectstack/linttsc --noEmitexited 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 statusshowed 0 lines andlint typecheckexited 0. Predicted direction: red. Observed: red.Ablation (
scripts/ablation-replace.mjs, on committed state, wrap mode). The mutation swapped the overlay'shidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED),forhidden: z.boolean().optional(),: anchor 1 → 0, blob1f93b520→e9ad3dec. Three spec files then read 10 failed / 139 passed, and the 10 are exactly the overlayhiddenpins: both members, the same-text pin, the door, the assembled channel, the hide-PUT refusal, the identity pin, and the three union-diagnostics rows. Theownerpins stayed green, as they should. The restore brought the blob back to HEAD1f93b520, withgit diff HEADat 0 bytes andgit status --porcelainat 0 lines. Predicted direction: red. Observed: red. (The metadata-protocol save-door pins resolve spec throughdist/, so they were not part of this ablation.)Gates.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat2a40c104cderived 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 includescheck-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 speccheck:*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 lanesdispatch-gateslists outside the derived total. The CLIintegrationtier does not apply (nopackages/clichange).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 at2a40c104c).18.view-item-owner-hidden-retired.tsas 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 byconfig, 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 ata05b32f8b. Its sentence 「A flattened view overlay keeps its ownownerandhidden…」 is replaced in this PR (amendment5859181450): the overlay pair is a separate family, with its own D2view-overlay-owner-hidden-removedand D3view-overlay-owner-hidden-retired..changeset/view-item-owner-hidden-retired.mdsays an overlay "still parses". It is left as landed, becausecheck-empty-changesetrefuses an edit to another PR's release note. This PR's changeset states the supersession instead. The release compiler should read the two together.{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/specbump.AssembledViewArtifactSchemais a plainz.union. Carrier: none.Generated by Claude Code