feat(spec,metadata-protocol): a stored filter the record-filter conversion leaves as stored is a TODO in os migrate meta --stored, not silence (#17321) - #20244
Conversation
Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ersion — WIP Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…t; changesets and docs — WIP Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…hannel exports — WIP Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
… a protocol fact — WIP Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 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 37ba6dc663ed5eae474953251721d8109dedadb1 && git checkout 37ba6dc663ed5eae474953251721d8109dedadb1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2dccb7d494ffc9190b7df36b4d91e94e9610a28a bafa19790e13eeaaa33b63d69c69c54ccd7e2b01 && git checkout -B drift-repro 2dccb7d494ffc9190b7df36b4d91e94e9610a28a && git merge --no-ff bafa19790e13eeaaa33b63d69c69c54ccd7e2b01
node scripts/docs-audit/affected-docs.mjs --json 2dccb7d494ffc9190b7df36b4d91e94e9610a28a
|
Contract reviewServed-tier: Read: card #17321, ruling 5644018752, ACCEPT 5852989736, release 5853818664, triage 5854129311, claim 5855123916, cross-repo note 5855902394, dev report 5856538210; PR #20244 object, body, 17 files, 6 commits, full diff vs merge-base 3875ae6, check-runs and two job step lists on the head; at the head: conversions/{types,apply,stored,index,registry}.ts and their three tests, metadata-protocol/{protocol,stored-migration,index}.ts and protocol.stored-migration.test.ts, dispatcher-error-vocabulary.ts, cli.mdx, both changesets, api-surface and export-origins root.json, cli migrate/meta.ts (stored branch), the D3 entry and its generated mirror, walk.ts, filter.zod.ts (isFilterAST, lowerFilterAST, LOGICAL_OPERATORS), filter-rule-array.ts (isRecordForm), page.zod.ts, lint authoring-rules.ts and validate-component-props.ts, the empty-changeset and no-major gate headers, runtime package.json and tsup.config.ts. Ran (source snapshots via git archive, sibling worktree node_modules, no build): an 862-case tsx probe of applyConversions at merge-base vs head; protocol.stored-migration.test.ts at head with @objectstack/spec aliased to head source (30/30) and, as control, aliased to merge-base source (4 failed / 26 passed); a formatter render of a TODO-only report; git merge-tree against origin/main and against PR #20238's head. NOT MEASURED: the objectui renderer's behaviour at the .objectui-sha pin (no objectui checkout; the inline-row claim is taken from the #20175 review chain and note 5855902394); CI job logs (proxy-blocked; step conclusions and check-run annotations read instead); the CLI door end to end (os migrate meta --stored not run; its closing line derived from meta.ts plus the rendered formatter); the runtime dist (not built; judged from the tsup entry and an importer grep); the check-changeset-no-major LEVEL axis in CI (its step was skipped after the deliberate red; judged from the gate's stated rule). ① Derived judgments(a) Reporting only, nothing rewritten differently: HOLDS. Probe corpus of 862 stacks — every one of the 13 decline branches (36 filter values), 11 lossless controls, 11 not-legacy controls, each placed at 15 sites (grid properties.filter, kanban dataSource.filter, grid defaultFilters, metric, a nested element:number inside page:card, a slot, a component with a string id, the three inline shapes on family types, an inline grid defaultFilters, data.provider object, empty staticData, a non-family record:related_list, defaultFilters off the grid) plus mixed pages, the entry fixture, a typeless and a non-string-type component and two empty stacks — run through applyConversions with includeRetired at merge-base 3875ae6 and at the head, once with sinks and once with none: 0 outputs differ (JSON byte-equal in all four pairings), 0 notice differences, identity of the no-sink return equal case by case, fixture.after matches on both, entry surface unchanged, 0 exceptions. The head's rewrite path is the base's: recordFilterToRules, astFilterToRules and legacyFilterToRuleArray keep the same accept condition and only replace undefined with a declined reason; the inline early-return became a declined branch inside rewrite, so an inline component is still returned as the same object. (b) The channel: HOLDS. A third lane beside onNotice / onConflict: CONVERSION_TODO_CODE = OS_METADATA_CONVERSION_TODO, ConversionTodoDetail {path, from, reason}, ConversionTodoNotice (notice shape, no (c) Every decline branch reports, with the right reason: HOLDS. At the head every declined site yields exactly one TODO at the site's path, code OS_METADATA_CONVERSION_TODO, conversionId page-component-filter-record-to-rule-array, the entry's surface, (d) The stored pass: HOLDS.
(e) The deliberate correction: (f) Scope. content/docs/deployment/cli.mdx: FORCED — the table introduces itself as "the operator-observable surface — what a run can actually report", and a TODO line plus a TODO-only (g) Surface: HOLDS. api-surface/root.json and export-origins/root.json each gain exactly three lines and lose none: CONVERSION_TODO_CODE (const), ConversionTodoDetail (interface), ConversionTodoNotice (interface), all from src/conversions/types.ts. No other symbol moves. packages/metadata-protocol has no api-surface or export-origins file. Lint & Repo Gates is green on the head. ② Semver level
③ Boundary flagsBlocking: none under the FAIL criteria.
CI at this head: 35 check-runs — 32 success, 2 skipped (Console Pin Gate; Packed-tarball smoke, opt-in), 1 failure (Check Changeset, the deliberate-correction gate, not in the required roster). Every required check is green: TypeScript Type Check with its four sub-jobs (workspace, consumer gates, source gates, debt ledger), Test Core and all six shards, Dogfood Regression Gate and its three shards, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard; also green: Dogfood Verify CLI, Build Docs, Spec property liveness, "The card this PR closes must claim this branch", "Part-of PR must not also close its card", both single-claim guards. Closing keywords: body line 1 is Implemented-by: VERDICT: PASS |
…nversion-todo-channel
… and the TODO listing Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ed D3 entry Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…nversion-todo-channel
… not a re-save refusal Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ed defaultFilters entry Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta from 7459968 (PASS 5856719041): six branch commits — two merges of main (4a86b39 ← fdb2669, 42ecba3 ← a9fb83e), two entry corrections (26e6970 element-data-source, 847f1ab object-grid defaultFilters), two mirror regenerations (bb0a830, 755acf3); one further move, the defaultFilters leg in the MEASURED test (26e6970), see ①(d). Read: PR object, body, 12 commits, 20 files, the 35 check-runs plus the Check Changeset and Lint & Repo Gates job steps and the Check Changeset annotations; reports 5857520707 and 5858406062 and amendment 5857535828; at the head: both D3 entry files and their registry.ts regions, the changeset note and its base, check-empty-changeset.mjs, check-required-contexts.mjs, pr-automation.yml, lint authoring-rules.ts / runtime-gate.ts / validate-component-props.ts, cli validate.ts / compile.ts / lint.ts / migrate/meta.ts, component.zod.ts, page.zod.ts, filter-rule-array.ts, conversions/registry.ts, stored-migration.ts, protocol.ts (saveMetaItem parse, migrateStoredMetadata), the §8 and MEASURED tests, build-migration-registry.ts. Ran: merge-tree of each merge against its own tree; a normalised interdiff of the PR patch at 7459968 (base 3875ae6) against the head (base a9fb83e); a detached worktree at the head with pnpm install --frozen-lockfile; under the shared lock, gen:migration-registry (git status empty afterwards); a 46-door / 39-site tsx probe on the head source; under one further lock hold, the metadata-protocol closure build (turbo 12/12), protocol.stored-migration.test.ts (30/30), eight spec test files (515 passed: the conversion trio, the object-grid defaultFilters pin, filter-rule-array-guidance, src/migrations), and a lint-rule probe on the built head. NOT MEASURED: the objectui renderer ("keeps rendering unchanged", the inline-row limit; no objectui checkout); the CLI door end to end (os migrate meta --stored not run; its exit code and closing line read from meta.ts); CI job logs (annotations and step conclusions read instead); check-changeset-no-major's LEVEL axis in CI (step 15 skipped after the deliberate red, as at 7459968); the derived gate families, not re-run here by rule. ① Derived judgments(a) The two corrected sentences, clause by clause, at the head. Element-data-source entry (
Object-grid defaultFilters entry (
(b) The precision gap. Judged: imprecise, not false; not blocking. In its own context "each filter left as stored" refers back to the set the same entry has just enumerated as "left exactly as stored" (the sentences from " (c) Mirrors and merges. Mirrors: in a clean worktree at the head, (d) What moved beyond the two sentences, their mirrors and the merges: ONE thing. Commit 26e6970 also edited ② Semver level
③ Boundary flagsDeliberate correction:
CI at this head: 35 check-runs, all completed — 32 success, 2 skipped (Console Pin Gate; Packed-tarball smoke, opt-in), 1 failure (Check Changeset, the deliberate-correction gate, not required). Every required context is success: Lint & Repo Gates (197 steps, none failed; step 11 registry mirror, step 119 vocabulary guard, step 156 required-context pin), TypeScript Type Check with its workspace / consumer gates / source gates / debt ledger jobs, Test Core and its six shards, Dogfood Regression Gate and its three shards, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; also success: Build Docs, Dogfood Verify CLI, Spec property liveness, both claim guards, the closing-keyword guards, Check PR Size, Check Documentation Links, Flag docs affected, Auto Label, filter. Closing keywords: body line 1 is Implemented-by: VERDICT: PASS |
…nversion-todo-channel # Conflicts: # packages/metadata-protocol/src/protocol.ts
…ate commands in the D3 entry and the pending note Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…ed D3 entry Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta from 755acf3 (PASS 5858721368): three branch commits — 930fae9 (merge of origin/main 2dccb7d, one import-hunk resolution in protocol.ts), 68294cc (the element-data-source D3 entry reason and two phrases of ① Derived judgments(a) The merge 930fae9 (parents 755acf3 and 2dccb7d). (b) The tightened sentences at the head (entry lines 77–89; mirror registry.ts 8080–8180, whose 99 inner lines equal the entry body's 99 line for line after the 4-space indent, diff exit 0 — verbatim). Clause by clause:
(c) Nothing else moved. The normalised interdiff ( ② Semver level
③ Boundary flagsDeliberate correction:
CI at this head: converged on the second poll (first snapshot: 34 runs with Test Core (5/6) in progress and the Test Core aggregate not yet posted). 35 check-runs, all completed — 32 success, 2 skipped (Console Pin Gate; Packed-tarball smoke (opt-in)), 1 failure (Check Changeset, the deliberate-correction gate, not required). Every required context is success: Lint & Repo Gates (197 steps, none failed; step 11 registry mirror, 119 vocabulary guard, 156 required-context pin, 167–168 mirrors), TypeScript Type Check with its workspace / consumer gates / source gates / debt ledger jobs, Test Core and its six shards, Dogfood Regression Gate and its three shards, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; also success: Build Docs, Dogfood Verify CLI, Spec property liveness, both claim guards, the two closing-keyword guards, Check PR Size, Check Documentation Links, Flag docs affected, Auto Label, filter. Closing keywords: body line 1 is Implemented-by: VERDICT: PASS |
Deliberate-correction red, carried to the merge queue (2026-09-27T20:02Z)
|
…s (ADR-0049) (objectstack-ai#20286) Fixes objectstack-ai#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 objectstack-ai#20085's disposition for the same key pair」. PR objectstack-ai#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. objectstack-ai#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 objectstack-ai#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 objectstack-ai#17152). It names its conversion by id in `reason`, which is the shape objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#20238, objectstack-ai#20255 and objectstack-ai#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 objectstack-ai#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 objectstack-ai#20255.** PR objectstack-ai#20255 (objectstack-ai#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 objectstack-ai#20255's filename, which would be an add/add collision. objectstack-ai#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 objectstack-ai#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** (objectstack-ai#20227's acceptance note 4, pre-existing): `AssembledViewArtifactSchema` is a plain `z.union`. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ot (objectstack-ai#20215) (objectstack-ai#20329) Fixes objectstack-ai#20215 Clause-②: no `os init` (the `app` and `plugin` templates) now wires every barrel `os generate` writes into, and declares the capabilities the flow scaffold runs on. After writing, `os g` loads the config again and says whether the new item reached the stack. When the write makes a config that used to load stop loading, `os g` refuses and takes the write back out. `os g` never edits a config. ## Premise, re-measured on `origin/main` `6a6a17b6` before any edit I built the CLI's dependency closure at `6a6a17b6`, ran `os init my-app -t app --no-install`, generated each of the seven types as `order_line`, and then ran `os validate`: | step | exit | what it printed | |---|---|---| | each `os g` | 0 | `Tip: Run objectstack validate to check your config` | | `os validate` | **0** | `Data: 2 Objects 5 Fields` · `UI: 0 Apps` · `Logic: 0 Flows` | Row 2 reproduces. With all six barrels wired by hand, `os validate` exits 1 with "flow 'order_line_flow' declares a 'record_change' trigger but `requires` does not include 'triggers'". Adding `requires: ['triggers']` gives exit 0, `UI: 1 Apps 1 Views 1 Dashboards 1 Actions`, `Logic: 1 Flows`. Booting that hand-wired project with `os serve --dev` measured two more facts: 1. **The view scaffold is refused at boot.** The server said "Invalid `views:` container from manifest 'com.example.my-app': the container's own `name` is 'order_line', which disagrees with the object key it binds to, 'my_app_order_line' … drop `name`, or set it to 'my_app_order_line'". `os validate` had passed it. So wiring `src/views` alone would have turned "the view is silently absent" into "the server does not boot" on the road's next step. 2. **`triggers` is not enough for a flow to run.** With `requires: ['triggers']` the server booted and printed "Flows: 1 flow(s) declared but the automation engine is not enabled — they will never run. Add requires: ['automation', 'triggers']". Each trigger plugin logged "automation service not available — … NOT installed". With both tokens it printed `Flows: 1 flow(s) 1 bound to triggers (record_change, schedule, time_relative, api) · 1 draft`. ## Row 1: the route, measured The route is: `os init` imports every generator's barrel, and `os g` reports whether its file reached the stack without ever editing the config. The floor is exact for every config shape. After writing, `os g` loads the config through the same `loadConfig` that `os validate` uses, folds it the way the counter does (`authoringRuleUnionStack`), and looks for the item's metadata `name` under the stack key (`singularToPlural(type)`). It never parses the config's text, so a reordered config, variables, `.mjs` and `packages[]` are all read the same way. `os g object`, `os g view` and `os g flow` (`order_line`) were run in each shape. The config hash is sha1, taken before and after the three runs: | config shape | config hash before → after | what `os g view` / `os g flow` said | `os validate` | |---|---|---|---| | (a) fresh `os init -t app` | `fbea6b0e` → `fbea6b0e` | reaches the stack, both | exit 0, `1 Views`, `1 Flows` | | (b1) hand-edited: imports and keys reordered, an extra import | unchanged | reaches, both | exit 0, counted | | (b2) hand-edited: `defineStack` fed from variables (`const ui = {…}; const stack = {…, ...ui}`) | unchanged | reaches, both | exit 0, counted | | (b3) the pre-fix `os init` config (`./src/objects` only) | unchanged | **not wired**: prints the import and the key (and `requires` for the flow) | exit 0, `0 Apps`, `0 Flows` | | (b4) the same config as `objectstack.config.mjs` | unchanged | reaches, both | exit 0, counted | | (b5) the `create-objectstack` `blank` shape (`./src/objects/index.js`, `requires: ['automation']`) | unchanged | **not wired**; the flow advice prints the whole list `requires: ['automation', 'triggers'],` | exit 0, `0 Flows` | | (c) no config | n/a | **not wired**: no config here, prints the lines | n/a | | (d) fresh `os init -t plugin` | unchanged | reaches, both | exit 0, counted | These rows were measured on `cae468f49`. The wiring-advice text in (b5) is from `c21f96460`. **Why not "`os g` edits the config".** That route would be a config editor, a capability the CLI has nowhere today: `os init` only ever writes a fresh config, and no command rewrites one. Its safety would rest on a recognizer for the author's file. For example, shape (b2) has no `defineStack` object literal to insert a key into, so an editor must detect it and fall back to the message. The route above changes no config byte in any shape, and needs no editor. That is why this is not a `needs_decision`. The editor route was not built, so its column is analysis, NOT MEASURED. **Empty barrels.** `os init` writes an `index.ts` containing only `export {};` for each directory the template puts nothing in, and never overwrites an existing one (keyed by renderer, so the objects barrel keeps its old write). The empty barrels must not break the build or typecheck: - `os validate` exits 0 on a fresh project: `UI: 0 Apps`, `Logic: 0 Flows`. - `os compile` exits 0. - The emitted project's own `tsc --noEmit` exits 0, measured by the existing `scaffold-emission-typechecks.test.ts`, which went **red** on the first version of this change. It led to one design change, described in the next paragraph. **`exportsOf`, not `Object.values`.** `Object.values(emptyBarrel)` does not type-check against `defineStack` for the keys that also accept a name-keyed map. With no export to infer from, TypeScript takes the element type from the map branch, whose `name` is optional. Measured: TS2322 on `actions`, `flows`, `dashboards` and `apps` of a fresh project, while `views` and `skills`, which have no map form, passed. Three alternatives were measured and all still failed: a spread, `Array.from`, and `.flat()`. The template therefore declares one local helper, `exportsOf`, whose element type comes from the barrel alone: an empty list while the barrel exports nothing, and the exported type once it does. Both states type-check with 0 errors, and a populated barrel is checked exactly as strictly as before. **Prefixed names survive.** Object names still go through `objectNameFor`. The reach check looks for exactly the name the scaffold writes (`itemName`, held equal to the emitted `name` by a pin, with and without a namespace). ## Row 2: the template declares what the flow needs Every template that wires `src/flows` declares `requires: ['automation', 'triggers']`. The list is derived as the union of the generators' own `requires`, which today is the flow scaffold's pair. The flow scaffold's header also states the pair. I chose this over "`os g flow` adds `triggers` to `requires`" because adding to `requires` is the same config editor. It includes `automation` as well because of the boot measurement above: without it, the flow validates and never runs. The one cost is that a fresh project that never holds a flow still mounts the automation engine and the trigger plugins. The config comment says both tokens can go if the project will never hold a flow. Where the stack carries a flow but lacks a token, `os g flow` warns and prints the whole `requires` list to use. ## What `os g` says now | verdict | when | exit | what is left on disk | |---|---|---|---| | reaches the stack | the loaded stack carries the item | 0 | the scaffold and the barrel line | | cannot run | reached, and the stack's `requires` lacks a token the scaffold runs on | 0 | as above; the whole `requires` list is printed | | not wired | the config loads and does not carry it, or there is no config | 0 | as above, the config untouched; the import and the key are printed | | refused | the config loaded before the write and does not load after it | **1** | nothing: the scaffold, the barrel line and any directory this run created are removed, and the tree is byte-identical | | cannot tell | the config did not load before the write either | 0 | as before this change; the verdict says it cannot tell | "Refused" is what the wired barrels make reachable. Measured on `c21f96460` in a fresh project: - `os g action approve` without an `approve` object: exit 1 with `defineStack`'s own "Action 'approve' references object 'my_app_approve' which is not defined in objects", tree unchanged. - `os g app crm`: exit 1, tree unchanged. - `os g flow` into a wired config without `requires`: exit 1, tree unchanged. The "cannot tell" row keeps the `objectstack-ai#20197` control: in a config that does not load, `os g dashboard sales` still generates, exit 0. ## Two fixes in `generate.ts`, same class, in place Both are the card's defect class, a scaffold that never reaches the stack or is refused once it does. Both are mechanical, both sit in this claim's file, and both are covered by this card's gates. - **The view container's `name` is its object key**, prefix included. The server registers a views container under that key and refuses one whose `name` disagrees. The `objectstack-ai#20197` census pinned the view's own `name` as unprefixed because no `os validate` gate judged it; that assertion is updated, and the reason is written into the pin. - **Barrel membership is asked of the compiler** (`barrelExportsBinding`), not by `indexContent.includes(binding)`. Measured: after `os g view order_line`, `os g view order` found `order` inside `orderLine` and exported nothing. The new `export {};` barrels would have made that bite `os g dashboard port`. ## Docs `content/docs/deployment/cli.mdx`, `os generate` section: - The four verdicts, and that `os g` never edits the config. - A **Collected as** column in the types table. - A view's `name` is its object key. - The example block now binds every scaffold to the object it generates first. `os g action approve` / `os g app crm` would now be refused in an `os init` project. "Typical Workflow": step 3 is now `os g flow opportunity`. As written, `os g flow lead_qualification` now counted (`1 Flows`) but `os validate` warned the flow "targets object 'my_crm_lead_qualification', which this stack does not define … the flow will never fire". With `opportunity`, only the draft-status advisory remains. Step 4 ("Validate everything") is true as written: measured on `4173b2067`, exit 0, `4 Objects`, `1 Flows`. ## Changeset `.changeset/20215-generate-scaffolds-reach-stack.md` is a `patch` for `@objectstack/cli`. It is a bug fix in a released package, `Clause-②: no` as claimed, the same shape `objectstack-ai#20197` landed its `os g` refusals under. It states what `os init` and `os g` now write and say that they did not before. The pending namespace-prefix note this PR falsified is corrected in place instead (next section), so this changeset carries no supersession paragraph. ## A pending release note corrected in place (DELIBERATE CORRECTION) This PR rewrites two sentences of `.changeset/20197-generate-object-namespace-prefix.md`, another card's PENDING release note. This PR makes both sentences false, and both notes compile into the same release. Commit `a03756d5e` carries that correction alone. Commit `da4aca641` then drops the supersession paragraph this PR's own changeset carried, because the sentences it pointed at no longer exist. `node scripts/check-empty-changeset.mjs --base origin/main` is red on this PR by design. It names that one file, "present on the merge base and CHANGED by this PR", and this is its DELIBERATE CORRECTION class: "your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed; restoring it from the base would put the false sentence back." The confirmation is the same-head contract review (seat answer `5860440515`; claim `5859284846` amended to name this file). The precedent is PR objectstack-ai#20284. Line 11, **One namespace source.**, last sentence: - Before: "`dashboard` and `skill` scaffolds name no object and never read the config." - After: "`dashboard` and `skill` scaffolds name no object, so a config that does not load does not stop them, but `os g` loads the config after every write, theirs included, to report whether the scaffold reaches the stack." Line 12, **Unchanged:**, second sentence: - Before: "A view's, action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before." - After: "An action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before; a view's own `name` now equals the object key it binds to, prefix included." Nothing else in that file moved: `git diff --word-diff` of `a03756d5e` shows these two sentences only (2 insertions, 2 deletions). Readings for this round on head `eedad4d37`, after merging `origin/main` `a78f731ad`: - `check-empty-changeset` exit 1, naming only the file above. - The 95 derived families all ran. `--ran` reports "95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN", and every exit is 0 except that one. - `pnpm lint` exit 0. - `node scripts/check-issue-citations.mjs --base origin/main` exit 0 (18 citations resolve). - CLI typecheck and the five per-PR scaffold pins: 81/81. - The nightly chains: 17/17. ## Pins - `packages/cli/test/generate-scaffold-wiring.test.ts` (unit, per-PR) covers: - the roster (every stack key is a key the stack schema declares; `itemName` is the emitted `name`); - the `app`/`plugin` templates (each barrel imported, wired, written; `requires` declared; the emitted project loads with every key a list); - barrel membership, the reach reader and the wiring lines; - `os init` keeping an author's barrel. - `packages/cli/test/generate-stack-reach.test.ts` (spawns the CLI, integration tier, per-PR, NOT `.e2e`) covers: - "refused", with the tree byte-identical, for an action with no object and a flow in a requires-less stack, plus a control where the same action generates once the object exists; - "not wired", for a pre-fix config (config byte-identical) and for no config; - "cannot run"; - `os g dashboard port` against the `export {};` barrel. - `packages/cli/test/generate-scaffolds-reach-stack.e2e.test.ts` (nightly) is triage's pin: `os init -t app`, then `os g` of every type, then `os validate` exits 0 with `2 Objects`, `1 Apps`, `1 Views`, `1 Dashboards`, `1 Actions`, `1 Flows`. `os compile`'s artifact carries every generated item, the skill included (`os validate`'s summary has no skills row). ## Verification Round 0 readings, on head `e33889d77` unless noted (patch round 1's readings on `eedad4d37` are in the DELIBERATE CORRECTION section): - `pnpm --filter @objectstack/cli typecheck` (tsc plus the test layer): exit 0. - `pnpm lint` (whole repo, not narrowed): exit 0. - CLI `unit` project: 230 files, 3296 tests, all pass on `01a556a52` (after merging `origin/main`). The only later commit touches one integration-tier test file. - CLI `integration` project, in two batches: 58 files, 485 pass, 1 skipped (not in a file this PR touches), on `01a556a52`. `generate-stack-reach.test.ts` passes 7/7 on `e33889d77`. - `OS_TEST_TIERS=nightly`, `generate-scaffolds-reach-stack.e2e.test.ts` plus the existing `generate-object-namespace-prefix.e2e.test.ts`: 17/17. - Derived gates (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`): 94 families, each run with its exit code recorded. `--ran` reports "94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN", all exit 0. On `01a556a52`, three gates first refused with exit 3 (a prerequisite: packages outside the CLI closure had no `dist`). They were re-run to exit 0 after building. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0 (27 citations resolve). - Merged `origin/main` at `6ac33a57d` (which carries PR objectstack-ai#20244's `cli.mdx` edit, a disjoint range), with a clean merge. The five commits `origin/main` gained since touch no `packages/cli` or `cli.mdx` path. ## Ablations Each ablation was committed first, mutated through `scripts/ablation-replace.mjs` (the anchor must hit, and the landing is proven by blob hash), run, and restored. Every restore was proven: the blob equals HEAD's (`init.ts` `3770e16c`, `generate.ts` `3a92cfa4`) and `git diff HEAD` is empty. All four ran on `e33889d77`, and every direction was red. | mutation | per-PR guards | nightly chain | |---|---|---| | revert the wiring (the templates wire `objects` only) | 9 failed | 8 failed (every `os g` prints wiring lines; counts; artifact) | | revert row 2 (delete the template's `requires` line) | 4 failed | 3 failed (`os g flow` refused; counts; artifact) | | view `name` back to the unprefixed stem | 3 failed (incl. the `objectstack-ai#20197` census) | n/a | | barrel check back to `includes` | 1 failed (`os g dashboard port`) | n/a | ## Acceptance notes - **The `npm create objectstack` starter is not wired.** `packages/create-objectstack/src/templates/blank/objectstack.config.ts` imports `./src/objects/index.js` alone and declares `requires: ['automation']`. It is read-only for this card. On that road (the north-star road starts there), `os g view` now says "not wired" with the lines, and `os validate` still counts 0 until the starter wires its barrels. Reported, not edited. - **`packages/spec/prompts/create-new-project.md`** (read-only here) lists `flows/`, `dashboards/` and `reports/` in its project tree, but its config sample wires `objects`, `actions` and `apps` only. - **`cli.mdx` about line 741** (the "Your First App" fixture callout, outside this claim's ranges) says the walkthrough's `os generate` commands would make the summary gain "`my_app_customer` and a `Logic:` row". In the `create-objectstack` starter those scaffolds are not wired, and `os generate action approve` binds to no declared object. - **The pending `.changeset/20197-generate-object-namespace-prefix.md`** had two sentences this PR makes false. On the seat's answer (A), they are corrected in place, as the DELIBERATE CORRECTION section above describes; `check-empty-changeset` stays red for that class by design. - **`os validate`'s summary counts no skills** (`collectMetadataStats` has no skills member), so the chain pin holds the skill through the compiled artifact. - **Hand-wiring an empty barrel with `Object.values` hits TS2322** for the map-supported keys. The cause is `MetadataCollectionInput`'s map branch in `packages/spec`, read-only here. The template avoids it with `exportsOf`. - Two runtime/validate disagreements the boot measurement surfaced are handed to the seat in the dev report rather than fixed here: - `os validate` passes a views container whose `name` disagrees with its object key, which `os serve` refuses; - `defineStack`'s trigger-capability rule accepts `triggers` without `automation`, and the server then never runs the flow. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17321
Clause-②: yes
Delivers the rest of ruling
5644018752item 2, the half that PR #20175 (d7c024133e) stopped at:This PR only adds reporting. Nothing is flattened. Nothing that was left as stored starts converting. Every stored body is rewritten exactly as it is on
main: the entry's §1–§7 pins pass unchanged, and a new pin compares the stack produced with a TODO sink to the one produced without one.Seam:
spec:ConversionTodoNotice (applyConversionsToStoredItem onTodo) → runtime:metadata-protocol migrateStoredMetadata / formatStoredMigrationReport (os migrate meta --stored, POST /api/v1/meta/_migrate-stored)The channel (
packages/spec/src/conversions/)CONVERSION_TODO_CODE(OS_METADATA_CONVERSION_TODO),ConversionTodoDetail(path,from,reason) andConversionTodoNoticeadd a third lane besideonNoticeandonConflict. The conflict twin is the precedent: the new lane has an optionalApplyConversionsOptions.onTodo, and an entry reaches it throughcontext.reportTodo, exactly as it reachescontext.reportConflict. There is no thrown sentinel, no return-value side channel and no global.StoredConversionOptionsisApplyConversionsOptionswith onlyincludeRetiredomitted, soonTodoreachesapplyConversionsToStoredItemwith no new plumbing. Pinned instored.test.ts.normalizeStackInput,validate,lint) are not wired. Only the stored pass passes a sink.The entry's decline branches (
registry.ts,page-component-filter-record-to-rule-arrayonly)The helpers now return
{ rules }or{ declined: why }where they used to returnundefined. The verdict logic is unchanged: still all-or-nothing, with the same accept set. Top-level$keys are now judged before field keys, which moves only the reason: the combinator is named even when a field key beside it would also decline. Every branch that leaves a legacy form in place emits exactly one TODO. The reason names the block (its type, plus itsidwhen it has one) and what blocks the rewrite. One pin per branch is in §8 ofpage-component-filter-record-to-rule-array.test.ts:$and/$or/$not, one or several)$or", plus any other$key beside it$key that is not a combinator ($text)$text, which is not a field"nullvalueequalsrule would test for null$null,$exists,$regex,$Gt)and/orgroup, or a list nesting oneorgroup"["a",">"])like,ilike)ViewFilterRuleSchemarefusesdata: { provider: 'value' }, adataarray,staticData).objectui-shadoes not carry the fix, so the decline stays.Branches argued NOT to emit:
[], a string, a number, or a mixed list of a rule object and an AST tuple (isFilterASTrefuses the last). The conversion never claimed these, and the door's own element-level refusal names them.filteron a component type outside the family (record:related_list), anddefaultFiltersoffobject-grid. These are not this entry's doors.!lowered || … || !(field in lowered)) shares the AST-operator reason. No input reached it separately in the probes.The stored pass (
packages/metadata-protocol)convertStoredItemDetailedforwardsonTodoand returnstodos.migrateStoredMetadata: a row carries its TODOs (rows[].todos, new typeStoredMigrationTodo) whatever its outcome. A row with nothing but TODOs is nowskipped, where it used to becanonical. Its reason says that the chain rewrites nothing there, and why.formatStoredMigrationReportnests aTODOline (the conversion id, the shape left as stored, the path and the reason) under each row wherever the row is listed. It adds one closing☐ TODO: N site(s) in M row(s)line and withholds "✓ Every row examined is already on protocol" when any TODO exists. A report with no TODO renders byte-identically tomain.Clean-ness: the choice and why.
storedMigrationCleanis unchanged (pending === 0 && failed === 0). Its own doc defines theskippedclasses as "outside what a body-canonicalization pass can do … still printed". It refuses to let them flip the verdict because no run of the command could clear them ("a gate failing on a condition its own tool has no lever for"). A TODO is exactly that, by ruling: the conversion must not flatten. So TODOs never move the verdict in either direction. A TODO-only row isskipped, and a row that also converts keeps the outcome its notices give it. Exit semantics for rows without a TODO do not move.Measured on the way: the two door kinds at the write path
The card, #20175's entry text and its changeset all say a record-form filter left as stored "is refused at its
filterdoor on its next save". ThroughsaveMetaItemthat holds only for the binding: a leftover indataSource.filter(a declared key of the strict component schema) fails the re-save. A leftover inproperties.filter/properties.defaultFilters(the openpropertiesbag) is re-saved successfully, because the component-props gate is advisory. Pinned inprotocol.stored-migration.test.ts("MEASURED — the write path judges the two door kinds differently"). So on--applythe mixed props-door row isrewritten: its lossless filter persists, and the next run reports the leftover as askippedTODO row. The mixed binding-door row isfailed, and its TODO says why. My TODO text makes no refusal claim ("not the rule-array form its door declares"). The entry docblock andsummaryI touch are corrected to match.Tests (numbers from the logs)
@objectstack/spec:vitest run --project local, 542 files / 15964 passed (2 todo) at23cd7bbed. The conversion trio (page-component-filter-record-to-rule-array,stored,conversions) 3 files / 313 passed at the final7459968a6.pnpm typecheck(tsc, scripts and the test layer) exit 0 at23cd7bbed.@objectstack/metadata-protocol: full suite 189 passed / 3 skipped files, 2706 tests at23cd7bbed.protocol.stored-migration.test.ts30/30 at7459968a6.pnpm typecheckexit 0; its tsconfig includessrc/**/*, so the new test file is checked too.@objectstack/runtime: full suite in two shards at7459968a6: 140 files / 1790 tests, then 139 files / 2119 passed (1 skipped).pnpm typecheck(tsc and the test layer) exit 0.@objectstack/metadata-core(conversion-layer importer): 16 files / 285 passed at23cd7bbed.@objectstack/cli(reads the report throughformatStoredMigrationReport/storedMigrationClean): not run locally. No unit-layer test reads the stored report, and its one stored-pass test,meta.stored-flow-resolution.integration.test.ts, is flows-only (flows emit no TODO) and sits in the integration layer, declared to CI.Ablation. I removed the
onTodoforwarding inconvertStoredItemDetailedthroughscripts/ablation-replace.mjs(anchor 1→0, blob46fff791a65d→d1b9bbfc0359) and ranprotocol.stored-migration.test.ts: 4 failed / 26 passed. The four TODO pins went red (the combinator-only row readcanonicalagain), and both controls stayed green. The restore brought the blob back to HEAD (46fff791a65d), andgit diff HEADis empty. No build or dist was involved: the test imports./protocol.jsfrom source.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat7459968a6derived 109 commands. Results: 106 exit 0 (check:skill-examplescounted after building theclient-reactclosure it needs), 1 exit 1 (check:empty-changeset, see below), and 2 exit 3 (check:dual-build-cjs-loadsandcheck:type-check-debt, which need every package'sdist: NOT MEASURED, and CI's).--ranreconciliation: 109 derived, 107 run, 2 NOT-MEASURED, 0 UNRUN, exit 0.check:api-surfaceandcheck:export-originsare regenerated and show 3 added, 0 removed (CONVERSION_TODO_CODE,ConversionTodoDetail,ConversionTodoNotice).check:dispatcher-error-vocabularypasses with the new foreign-vocabulary row.⚠ A pending release note is corrected here: please confirm
check:empty-changesetis red on purpose. This is its DELIBERATE CORRECTION class: this PR edits.changeset/17321-record-filter-d2-conversion.mdfrom #20175, which has not been released yet.os migrate meta --storeddoes not list these rows yet: a row the conversion leaves as stored reports there as already on protocol" is made false by this PR. It now reads "os migrate meta --storedlists each filter left as stored as a TODO under its row."filterdoor on its next save" is measured false for the twopropertiesdoors (see the section above). It now states the refusal per door kind.Restoring the file from base would put a false sentence into the release, so the gate stays red until a person confirms these two corrections.
Scope beyond the claimed file surface (declared)
content/docs/deployment/cli.mdx: its--storeddecline table says it is "the operator-observable surface — what a run can actually report". This PR makes a new row class observable, so without the new row the table would become false. One row was added, and "exits1" is scoped to "an old dialect this pass can convert". ThemigrateStoredMetadataJSDoc names this table as its operator-facing twin.packages/metadata-protocol/src/index.ts: one line re-exportingStoredMigrationTodo, beside theStoredMigrationNoticeit parallels..changeset/17321-record-filter-d2-conversion.md: see above.Not touched:
packages/cli,packages/metadata-core,protocol.ts'ssaveMetaItemregion, and.objectui-sha.Acceptance notes
os migrate meta --storedstill ends in "Stored metadata is already on protocol N — nothing to rewrite" under a TODO list. The CLI's closing line (packages/cli/src/commands/migrate/meta.ts, theclean && !applybranch) reads onlystoredMigrationClean. That is true for a TODO-only run, and today already for a run whose only rows are non-canonical-type skips. The formatter withholds its own "already on protocol" line when a TODO exists, but the CLI line is outside this card (packages/cli). It is reported to the seat as a finding.18.element-data-source-and-object-block-filter-rule-arrayand its generated mirror inmigrations/registry.tssay a stored record-form filter "is refused at thefilterdoor on its next save". That is measured false for the twopropertiesdoors (see above). It is reported to the seat as a release-text finding..objectui-shacarries objectui#10767 is a follow-up the PM carries. This PR keeps the decline and words its TODO as a limit of the pinned renderer.Generated by Claude Code