Skip to content

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

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-17321-conversion-todo-channel
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-17321-conversion-todo-channel

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #17321
Clause-②: yes

Delivers the rest of ruling 5644018752 item 2, the half that PR #20175 (d7c024133e) stopped at:

「A record carrying $and / $or / $not is passed through unchanged and reported as a structured TODO naming the page/block and the combinator — os migrate meta --stored prints the list; the operator can answer "did it convert my row" from that output.」

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) and ConversionTodoNotice add a third lane beside onNotice and onConflict. The conflict twin is the precedent: the new lane has an optional ApplyConversionsOptions.onTodo, and an entry reaches it through context.reportTodo, exactly as it reaches context.reportConflict. There is no thrown sentinel, no return-value side channel and no global.
  • StoredConversionOptions is ApplyConversionsOptions with only includeRetired omitted, so onTodo reaches applyConversionsToStoredItem with no new plumbing. Pinned in stored.test.ts.
  • The load-time consumers (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-array only)

The helpers now return { rules } or { declined: why } where they used to return undefined. 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 its id when it has one) and what blocks the rewrite. One pin per branch is in §8 of page-component-filter-record-to-rule-array.test.ts:

branch reason names
record: a top-level combinator ($and / $or / $not, one or several) "carries the combinator $or", plus any other $ key beside it
record: a top-level $ key that is not a combinator ($text) "carries the top-level key $text, which is not a field"
record: a null value the key; the renderer skips it today, and an equals rule would test for null
record: an array value, or a non-plain object value the key and the value kind
record: an empty operator object the key
record: an operator outside the one table ($null, $exists, $regex, $Gt) the key and the operator
record: a nested object whose key is not an operator the key and the inner key
AST: an and / or group, or a list nesting one "an or group"
AST: neither one comparison nor a flat list of them the shape
AST: a comparison the AST refuses to lower (["a",">"]) the comparison and the first sentence of the AST's refusal
AST: an operator with no rule word (like, ilike) the field and the operator
door: a mapped rule that ViewFilterRuleSchema refuses the rule and the door's first issue
node: the block renders inline rows (data: { provider: 'value' }, a data array, staticData) the inline shape, and that the objectui renderer this release pins cannot match a rule array on inline rows. This is a limit of the pinned renderer, not a protocol fact: objectui#10767 fixed it upstream, but .objectui-sha does not carry the fix, so the decline stays.

Branches argued NOT to emit:

  • A value that is not a legacy form at all: an existing rule array, [], a string, a number, or a mixed list of a rule object and an AST tuple (isFilterAST refuses the last). The conversion never claimed these, and the door's own element-level refusal names them.
  • A filter on a component type outside the family (record:related_list), and defaultFilters off object-grid. These are not this entry's doors.
  • The lowered-shape guard (!lowered || … || !(field in lowered)) shares the AST-operator reason. No input reached it separately in the probes.

The stored pass (packages/metadata-protocol)

  • convertStoredItemDetailed forwards onTodo and returns todos.
  • migrateStoredMetadata: a row carries its TODOs (rows[].todos, new type StoredMigrationTodo) whatever its outcome. A row with nothing but TODOs is now skipped, where it used to be canonical. Its reason says that the chain rewrites nothing there, and why.
  • formatStoredMigrationReport nests a TODO line (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 to main.

Clean-ness: the choice and why. storedMigrationClean is unchanged (pending === 0 && failed === 0). Its own doc defines the skipped classes 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 is skipped, 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 filter door on its next save". Through saveMetaItem that holds only for the binding: a leftover in dataSource.filter (a declared key of the strict component schema) fails the re-save. A leftover in properties.filter / properties.defaultFilters (the open properties bag) is re-saved successfully, because the component-props gate is advisory. Pinned in protocol.stored-migration.test.ts ("MEASURED — the write path judges the two door kinds differently"). So on --apply the mixed props-door row is rewritten: its lossless filter persists, and the next run reports the leftover as a skipped TODO row. The mixed binding-door row is failed, and its TODO says why. My TODO text makes no refusal claim ("not the rule-array form its door declares"). The entry docblock and summary I touch are corrected to match.

Tests (numbers from the logs)

  • @objectstack/spec: vitest run --project local, 542 files / 15964 passed (2 todo) at 23cd7bbed. The conversion trio (page-component-filter-record-to-rule-array, stored, conversions) 3 files / 313 passed at the final 7459968a6. pnpm typecheck (tsc, scripts and the test layer) exit 0 at 23cd7bbed.
  • @objectstack/metadata-protocol: full suite 189 passed / 3 skipped files, 2706 tests at 23cd7bbed. protocol.stored-migration.test.ts 30/30 at 7459968a6. pnpm typecheck exit 0; its tsconfig includes src/**/*, so the new test file is checked too.
  • @objectstack/runtime: full suite in two shards at 7459968a6: 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 at 23cd7bbed.
  • @objectstack/cli (reads the report through formatStoredMigrationReport / 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 onTodo forwarding in convertStoredItemDetailed through scripts/ablation-replace.mjs (anchor 1→0, blob 46fff791a65d→d1b9bbfc0359) and ran protocol.stored-migration.test.ts: 4 failed / 26 passed. The four TODO pins went red (the combinator-only row read canonical again), and both controls stayed green. The restore brought the blob back to HEAD (46fff791a65d), and git diff HEAD is empty. No build or dist was involved: the test imports ./protocol.js from source.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 7459968a6 derived 109 commands. Results: 106 exit 0 (check:skill-examples counted after building the client-react closure it needs), 1 exit 1 (check:empty-changeset, see below), and 2 exit 3 (check:dual-build-cjs-loads and check:type-check-debt, which need every package's dist: NOT MEASURED, and CI's). --ran reconciliation: 109 derived, 107 run, 2 NOT-MEASURED, 0 UNRUN, exit 0. check:api-surface and check:export-origins are regenerated and show 3 added, 0 removed (CONVERSION_TODO_CODE, ConversionTodoDetail, ConversionTodoNotice). check:dispatcher-error-vocabulary passes with the new foreign-vocabulary row.

⚠ A pending release note is corrected here: please confirm

check:empty-changeset is red on purpose. This is its DELIBERATE CORRECTION class: this PR edits .changeset/17321-record-filter-d2-conversion.md from #20175, which has not been released yet.

  1. Its sentence "os migrate meta --stored does 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 --stored lists each filter left as stored as a TODO under its row."
  2. Its sentence "…is refused at its filter door on its next save" is measured false for the two properties doors (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 --stored decline 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 "exits 1" is scoped to "an old dialect this pass can convert". The migrateStoredMetadata JSDoc names this table as its operator-facing twin.
  • packages/metadata-protocol/src/index.ts: one line re-exporting StoredMigrationTodo, beside the StoredMigrationNotice it parallels.
  • .changeset/17321-record-filter-d2-conversion.md: see above.

Not touched: packages/cli, packages/metadata-core, protocol.ts's saveMetaItem region, and .objectui-sha.

Acceptance notes

  • os migrate meta --stored still 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, the clean && !apply branch) reads only storedMigrationClean. 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.
  • The re-save refusal claim outside this PR's files: the semantic entry 18.element-data-source-and-object-block-filter-rule-array and its generated mirror in migrations/registry.ts say a stored record-form filter "is refused at the filter door on its next save". That is measured false for the two properties doors (see above). It is reported to the seat as a release-text finding.
  • Dropping the inline-row decline once .objectui-sha carries 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

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/spec, touching 28 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/spec/api-surface/root.json, packages/spec/export-origins/root.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), migrateStoredMetadata (symbol, a method of class ObjectStackProtocolImplementation))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/spec/api-surface/root.json, packages/spec/export-origins/root.json, …) — pages documenting those are invisible to this run
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2dccb7d494ffc9190b7df36b4d91e94e9610a28a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 37ba6dc663ed5eae474953251721d8109dedadb1 — the merge of head bafa19790e13eeaaa33b63d69c69c54ccd7e2b01 into base 2dccb7d494ffc9190b7df36b4d91e94e9610a28a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2dccb7d494ffc9190b7df36b4d91e94e9610a28a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7459968a65795668188a5b976ffae38fc8e819c3

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 to), optional ApplyConversionsOptions.onTodo, and ConversionContext.reportTodo built inside applyConversions only when onTodo is supplied — the exact shape of reportConflict. No thrown sentinel, no global, no return-value side channel anywhere in the diff; applyConversionsToStoredItem's signature is untouched (stored.ts changes are 4 doc lines) and StoredConversionOptions is still Omit of includeRetired, so onTodo reaches the stored seam with no plumbing (pinned in stored.test.ts). The vocabulary row for OS_METADATA_CONVERSION_TODO (objlitconst, door none, foreign-vocabulary) is added; Lint & Repo Gates step 119 "Dispatcher error-code vocabulary guard" is success on the head.

(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, from = JSON of the value, and a reason of the shape "On the type block [id], this filter …blocker…. Left as stored, it keeps loading unchanged, but it is not the rule-array form its door declares — rewrite it by hand." (0 shape violations over all TODOs). The combinator branch names the combinator(s): $or, $and, $not, "$or and $not", and names it even when a null field key beside it would decline first ({owner_id: null, $or: …}) or a non-combinator $ key rides along ("$and (its top-level $text is not a field either)"). AST groups name the group ("an or group", "an and group"). The remaining branches name the key and null / array / non-plain value / empty operator object / the unmapped operator ($exists, $null, $regex, $Gt) / the nested non-operator key / the AST operator (like, ilike) / the comparison the AST refuses with the refusal's first sentence / the rule the door refuses with its first issue. Missed branches: none found — the only silent paths are values that are not a legacy form at all ([], a rule array, a string, a number, a boolean, null, a mixed rule-plus-tuple list, [[]], ['and'], a tuple beside an object, and the infix [cond, 'or', cond], which isFilterAST and lowerFilterAST both refuse, so the platform never lowered it). TODOs where nothing was declined: none — non-family doors (record:related_list) and defaultFilters off the grid give 0/0 for every value; lossless values give 1 notice / 0 TODO at every family door; inline blocks give a TODO only for a legacy value (own blocker first, else the inline reason). The inline reason states the pinned renderer's limit — "the objectui renderer this release pins cannot match a rule array against inline rows — it would exclude every row — so no rewrite here is lossless yet" — not a protocol fact; the renderer fact itself is NOT MEASURED here and is consistent with note 5855902394. No TODO text makes a refusal claim; "not the rule-array form its door declares" is true for all three doors (ElementDataSourceSchema and every ComponentPropsMap door declare the rule array). Wording note, not a falsity: the combinator reason says flattening "would change which rows the filter selects" also for a lone $and, which is true only in the measured sense that the pinned renderer misread every combinator key (objectui#6948).

(d) The stored pass: HOLDS.

  • convertStoredItemDetailed passes onTodo and returns todos; flows return todos: []. Ablation control run here: with @objectstack/spec aliased to merge-base source, exactly the four TODO pins go red (4 failed / 26 passed) and both CONTROL pins stay green; with head source 30/30.
  • A TODO-only row is skipped with a reason naming the count, never canonical (test 38: canonical 0, skipped 1, rows[0].todos length 1, path and $or named; metaRows byte-equal before/after). A row that also converts keeps pending / rewritten / failed and carries its todos (tests 42, 43).
  • formatStoredMigrationReport nests a TODO line under the row wherever it is listed (converting, skipped, failed), adds one closing "☐ TODO: N site(s) in M row(s)" line, and withholds "✓ Every row examined is already on protocol" when any row has todos. Rendered here for a TODO-only report: the "already on protocol" line is absent; a report with no todos renders as before (the branch is otherwise untouched).
  • storedMigrationClean is unchanged (pending === 0 && failed === 0). The argument is sound against today's treatment of skipped: no skip class counts toward the verdict now, and a TODO is a site no run of this pass may clear by ruling; test 39 pins clean === true for a TODO-only preview and apply, with zero history rows.
  • The CLI closing line: meta.ts's clean && !apply branch prints "Stored metadata is already on protocol N — nothing to rewrite" directly after the formatter's "☐ TODO … left as stored" line. Measured as a user-visible statement: it is false ("already on protocol") for a TODO-only run and self-contradicting beside the TODO block. Not created by this PR: before it, the same run printed "already on protocol" twice (formatter and CLI), both false; after it, one true block and one false line — the count of false lines drops from 2 to 1 and the contradiction is new. The line is packages/cli's, excluded by claim 5855123916, and already false for every existing skip class, so it does not belong in this PR; it is a one-condition follow-up (see ③).

(e) The deliberate correction: .changeset/17321-record-filter-d2-conversion.md (added by #20175, unreleased). Rewritten sentences, judged: (1) "Such a page keeps loading and rendering unchanged" — carried over, true (the stored seam replays without validating). (2) "its filter door refuses the form: at dataSource.filter on the page's next save" — TRUE, measured: PageComponentSchema is a strictObject whose dataSource is ElementDataSourceSchema (page.zod.ts 266, 395); test 43 drives the binding-door mixed row through saveMetaItem on --apply and it is failed, run here at head. (3) "at a block's properties.filter / properties.defaultFilters only as the component-props gate's advisory finding (os validate) — a re-save through the metadata API is not refused there, measured" — TRUE: properties is z.record(z.string(), z.unknown()) (page.zod.ts 324); validateComponentProps is tier advisory, commands ALL, surfaces CLI_ONLY (authoring-rules.ts 979–986) and os validate runs it through runAuthoringRules('validate', …) (validate.ts 377); test 43: the props-door mixed row is rewritten, its combinator persisted byte-identical, and the re-run reports it skipped with the TODO. (4) "for a combinator record that refusal no longer renders the combinator as a field … names the combinator" — unchanged #20175 text, not rewritten here. (5) "os migrate meta --stored lists each filter left as stored as a TODO under its row" — TRUE at the head (pushTodos under every listed row; probe: one TODO per declined site). Needed: the old sentence "does not list these rows yet: a row the conversion leaves as stored reports there as already on protocol" is made false BY THIS PR (test 38 at head; the same pin is red with merge-base spec source), and the old "is refused at its filter door on its next save" was already false for the two properties doors (test 43 passes at head against the unchanged saveMetaItem). Restoring the base text would republish two false sentences. CI: "Check Changeset" is failure at step 12 "Reject an empty-frontmatter changeset added by this PR" (check-empty-changeset.mjs); its annotation names exactly one file, .changeset/17321-record-filter-d2-conversion.md, under the foreign-changeset rule #17712 with the DELIBERATE CORRECTION remedy; step 11 "Require a changeset" succeeded and the new changeset's frontmatter is non-empty, so rule 1 is not in play. Red only for the deliberate reason. Consequence worth recording: steps 13–15 (ADR-0087 disposition, allow-major re-read, the major guard with the Clause-② LEVEL axis) were skipped, so that axis is NOT MEASURED in CI on this head. "Check Changeset" is not in main's required-check roster.

(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 skipped row are a new reportable class, so the table is false without the row; the exit-code sentence "a deployment with rows still carrying an old dialect exits 1" is false without the scoping (a TODO-only row carries an old dialect and exits 0) and had been false since #20175 (such a row read canonical); migrateStoredMetadata's JSDoc names this table as its operator-facing twin (protocol.ts 16673). packages/metadata-protocol/src/index.ts: FORCED by the new field — StoredMigrationRow.todos is StoredMigrationTodo[], and the element type needs a nameable root export exactly as StoredMigrationNotice has for notices; the changeset lists it as a public gain. The pending changeset: FORCED (e). registry.ts within the entry beyond the decline branches: the docblock's base text "the ruled report of these rows … is NOT delivered here: the conversion layer has no TODO channel" is false at the head, so its rewrite is forced, and its new door paragraph matches the measurement; the summary string is release text (composeSpecChanges spec-changes.ts 250, build-upgrade-guide.ts 101), its base wording "is refused at its door on its next save" was false for the props doors and silent on the TODO, and the committed docs/protocol-upgrade-guide.md does not carry this entry (grep 0), so nothing generated is stale; rendersInlineRows now returns the shape string with truthiness preserved (staticData ? … : undefined equals Boolean(staticData); the probe shows identical inline verdicts including an empty staticData []) and the string is what the TODO names; quoteKeys / firstSentence / dollarKeysReason / describeBlock build reason text only and sit off the rewrite path; judging $ keys before field keys moves the reason only (probe: identical outputs). All of it is reporting-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

Clause-②: yes is right: the PR widens @objectstack/spec's public surface (three exports plus optional onTodo / reportTodo fields) and @objectstack/metadata-protocol's (StoredMigrationTodo, StoredMigrationRow.todos). .changeset/17321-conversion-todo-channel.md grades @objectstack/spec: minor and @objectstack/metadata-protocol: minor, carries the **Clause-②: yes** line, and declares no major — it satisfies check-changeset-no-major's stated LEVEL axis (a Clause-② PR must grade at least one package whose published source it moves at minor or above; both do) and its launch-window guard. Runtime carries no changeset, and that is correct: packages/runtime/package.json publishes files: [dist] with a single . export, tsup builds entry: ['src/index.ts'], src/index.ts does not import dispatcher-error-vocabulary.ts, and the only non-comment importer is error-envelope.conformance.test.ts — the vocabulary file is not in the published dist (judged from the build entry and the importer grep; the dist was not built here). Both packages are in the 69-member fixed group, so the release bumps them together either way; the level declaration is what the gate reads. The gate's LEVEL-axis step did not run in CI on this head (skipped after the deliberate red).

③ Boundary flags

Blocking: none under the FAIL criteria.
Non-blocking:

  • (new, not in the dev's list) The D3 entry packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts lines 81–82 and its generated mirror packages/spec/src/migrations/registry.ts lines 8009–8010 still say "os migrate meta --stored does not list these rows yet: a row the conversion leaves as stored reports there as already on protocol." That sentence is made false by THIS PR — it is the very sentence the PR corrects in the changeset twin — and the PR leaves it in a text os migrate meta prints to authors. Should ride this PR (same card, same family, the same one-sentence correction, plus os-regen of the mirror), not a follow-up.
  • (1) The same entry, lines 79–80 / mirror 8007: "is refused at the filter door on its next save" — measured false for the two properties doors (test 43); pre-existing since feat(spec): a stored record-form filter at the rule-array doors converts to the rule array where lossless — D2 page-component-filter-record-to-rule-array (#17321) #20175; not blocking; should ride the same edit as the point above rather than a separate follow-up. Merge note for both: PR fix(spec): a joined report draws no chart — retire blocks[].chart and refuse a joined container chart (#20161) #20238 also touches migrations/registry.ts (a new retired-keys entry, different region); the mirror is generated, so regenerate after merge.
  • (2) The CLI closing line (packages/cli/src/commands/migrate/meta.ts, the clean && !apply branch) prints "already on protocol N — nothing to rewrite" after the ☐ TODO block. Not blocking; pre-existing for every skip class; packages/cli is excluded by the claim, so a follow-up for domain:cli (one condition: reword or withhold when any row carries todos).
  • (3) Retiring the inline-row decline once .objectui-sha carries objectui 17b323e5ca (the pin is f8a9d0fb05, untouched here). Not blocking; not this PR; the PM's follow-up as the dev recorded.
  • Merge-conflict risk: git merge-tree --write-tree origin/main head is clean (exit 0, tree dbf08bf76); head against fix(spec): a joined report draws no chart — retire blocks[].chart and refuse a joined container chart (#20161) #20238's head 94a2c63 is clean (exit 0, tree ee951e4e5). This PR does not touch CONVERSIONS_BY_MAJOR; fix(spec): a joined report draws no chart — retire blocks[].chart and refuse a joined container chart (#20161) #20238's registry.ts hunks (a new entry inserted after this one at about line 10770 and the [18] tail) are adjacent to but do not overlap the entry region this PR rewrites. The branch is 15 commits behind main; the three upstream commits sharing files (registry.ts, cli.mdx, filter.zod.ts) do not overlap these hunks.

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 Fixes #17321 and it is the only closing keyword in the body; #20175 and objectui#10767 appear as plain mentions. The PR is still a draft, mergeable_state unstable (the changeset gate).

Implemented-by: claude/issue-17321-conversion-todo-channel
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 755acf300f5ee20aae5fac13d4cf5be25fb574f7

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 (18.element-data-source-and-object-block-filter-rule-array.ts lines 77–86; mirror migrations/registry.ts 8088–8097):

  • "Such a row keeps loading unchanged (applyConversionsToStoredItem replays the chain without validating, by its own contract)" — carried text; TRUE for loading: every declined site is returned byte-identical (probe: value unchanged and input untouched at all 39 sites; §8 "reporting writes nothing").
  • "and its filter door refuses the form: at dataSource.filter on the page's next save" — TRUE. ElementDataSourceSchema is a strictObject whose filter is z.array(ViewFilterRuleSchema, { error: ruleArrayFilterError(...) }) (page.zod.ts 194, 226–228); PageComponentSchema is a strictObject carrying dataSource: ElementDataSourceSchema (266, 395); saveMetaItem parses every item through resolveOverlaySchema(type, item).safeParse and refuses with INVALID_METADATA / 422 (protocol.ts 16165–16187). Probe: the record combinator, a flat record, a bare string, a bare number, null and an AST tuple array are each refused at filter (AST at filter.0); a rule array passes. MEASURED test, run here at the head: the binding row deal_room is failed on --apply, and the log shows the refusal text.
  • "at a block's properties.filter — like properties.defaultFilters, a key of the open properties bag — only as the component-props gate's advisory finding (os validate), since a re-save through the metadata API is not refused there" — TRUE. properties is z.record(z.string(), z.unknown()) (page.zod.ts 324), so the save door never judges a prop by component type; validateComponentProps is tier: 'advisory', commands: ALL (= AUTHORING_COMMANDS ['validate','build','lint']), surfaces: CLI_ONLY (authoring-rules.ts 153, 360–363, 979–986), and the runtime publish gate runs only rules whose surfaces include 'runtime-publish' (runtime-gate.ts 551, 559). Lint probe on the built head: a combinator at properties.filter and at properties.defaultFilters yields one warning finding (rule component-props-invalid) on each of validate, build and lint, never an error. MEASURED test at the head: the deal_desk props row and the deal_grid defaultFilters row are rewritten with the combinator persisted byte-identical (30/30). "(os validate)" names one of the three commands that run the rule (validate.ts 377, compile.ts 420, lint.ts 673) — imprecise beside the twin entry's "(os validate, os build, os lint)", not false.
  • "For a combinator record that refusal names the combinator and says why no rule spells it." — TRUE at all three door kinds: one ruleArrayFilterError map (filter-rule-array.ts 141–210) renders "$or is a combinator, not a field, and the rule array has no spelling for it: its rules only AND, so $or and $not cannot be written as rules at all" (probe at dataSource.filter, the four object-* filter doors and defaultFilters); the lint rule passes issue.message through (validate-component-props.ts 316; lint probe namesOr true, no field: '$or' rendering).
  • "os migrate meta --stored lists each filter left as stored as a TODO under its row, naming the block and what blocks the rewrite" — TRUE for every filter this entry enumerates as left as stored (the combinator; the null value; $null / $exists; the AST like; array or object comparands; AST groups; every filter of an inline-row block): pushTodos nests "TODO conversionId: from left as stored at path — reason" under the row wherever it is listed (stored-migration.ts 282–285, from the converting, skipped and failed lists), and the reason opens "On the type block [id], this filter …blocker…" (conversions/registry.ts 10890–10894; probe; §8 census, one TODO per branch). The precision limit is (b).
  • "a row whose only finding is such a TODO is reported skipped" — TRUE: protocol.ts 16984–17000 (todos without notices → outcome 'skipped', reason counts the sites); test lines 654–685 at the head.
  • "and the run's exit code does not change for it." — TRUE: storedMigrationClean is pending === 0 && failed === 0 (stored-migration.ts 195), a TODO-only row is skipped, and meta.ts sets exitCode = 1 only on !clean (724–725, 768); test 687–700 pins clean for preview and apply. CLI not run; derived from meta.ts.

Object-grid defaultFilters entry (18.object-grid-default-filters-rule-array.ts lines 54–64; mirror registry.ts 11441–11451):

  • "What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, the bare string or number this key also took, and any filter on a grid whose rows are inline …, for the reason its sibling gives" — carried text; "left exactly as stored" TRUE for each (probe at properties.defaultFilters: combinator, null, bare string, bare number and the AST like are returned unchanged); "keeps rendering as it does today" NOT MEASURED (renderer).
  • "and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint)" — TRUE. defaultFilters: z.array(ViewFilterRuleSchema, { error: ruleArrayFilterError({ surface: '… defaultFilters …', migration: 'object-grid-default-filters-rule-array' }) }).optional() (component.zod.ts 2834–2839); probe: the combinator, null, string, number, a flat record, a null field and a $null operator are each refused at defaultFilters, the AST tuple array at defaultFilters.0, and a rule array or [] passes. The command list matches the declaration (commands: ALL, three commands) and each command surfaces the advisory (validate.ts 694, compile.ts 216–224, lint.ts grades a warning; lint probe: one warning on each of the three); surfaces: CLI_ONLY keeps it off the publish gate.
  • "since a re-save through the metadata API is not refused there" — TRUE, MEASURED at the head: deal_grid rewritten, properties.defaultFilters persisted as the combinator.
  • ": a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table" — TRUE: the door's error map is filter's by declaration (2828–2833); probe for { stage: { $null: true } }: "so this filter becomes [{ field: 'stage', operator: …, value: … }] … Full conversion table: migration object-grid-default-filters-rule-array", and for the combinator the $or sentence with the same pointer. "the message the filter door gives" is the same map rendered with this door's surface and pointer, not a byte-identical string; the sentence's own next clause says the pointer is this entry's, so read as intended.
  • "and a bare string or number or an AST tuple array with the schema's plain type refusal" — TRUE: "Invalid input: expected array, received string" / "received number" at defaultFilters, "Invalid input: expected object, received array" at defaultFilters.0 (probe; null likewise "received null").

(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 "⚠️ A filter carrying $and / $or / $not is left exactly as stored" through "So is every filter … of a component whose rows are INLINE"), and every member of that set reports (probe; §8 one-TODO-per-branch census; the inline-row pins). A bare string or number is never named in this entry as left as stored — that phrase is the twin entry's, and the twin makes no listing claim. The limit, measured so the seat has it: a stored bare string, bare number, null or [] at properties.filter of the four object-* blocks (the doors that took z.unknown()), at properties.defaultFilters, or at dataSource.filter is neither converted nor reported (probe: 0 notices, 0 TODOs, value unchanged at all 17 such sites; §8 control "a value that is not a legacy form is neither converted nor reported"; conversions/registry.ts 10874–10875 if (!mapping) return holder), so such a row reads canonical and the run says "already on protocol" about it. That limit was in the PR at 7459968 and in the changeset note's twin sentence when 5856719041 passed them; the universal reading is the only one the sentence does not support. If the seat wants that reading closed, the exact wording: replace "os migrate meta --stored lists each filter left as stored as a TODO under its row, naming the block and what blocks the rewrite;" with "os migrate meta --stored lists each such filter as a TODO under its row, naming the block and what blocks the rewrite (a value that is not a record or AST form at all — a bare string or number one of the former z.unknown() doors took — is neither converted nor reported, and its row reads as already on protocol);", then gen:migration-registry. The same one-word tightening ("each such filter") fits the changeset note's last sentence.

(c) Mirrors and merges. Mirrors: in a clean worktree at the head, gen:migration-registry rewrote registry.ts ("266 semantic, 214 retired-key, 199 retired-def") and git status --porcelain was empty afterwards, so the committed mirror is byte-identical to what the generator produces from the entry files at the head; a line-for-line comparison of the two regions (8016–8113 and 11396–11467) with their entry bodies is identical (96/96 and 70/70 inner lines); CI Lint & Repo Gates step 11 "Migration registry matches its entry files" is success on the head. Each regeneration is its own commit after its entry edit (bb0a830 after 26e6970, +8/−4; 755acf3 after 847f1ab, +6/−3), touching only registry.ts with the entry's own line delta — nothing hand-edited. Merges: git merge-tree --write-tree 7459968a6 fdb26698f = b6e85bf88f74bed5f482784ed89eb0d9d1c3b024 = 4a86b39bd^{tree}; git merge-tree --write-tree bb0a8300d a9fb83ef0 = 2dc4e4069bf6b4e363494cac1b5173de0ecc6c56 = 42ecba319^{tree}. Each merge commit's tree equals git's own clean three-way merge of its two parents, so neither merge carries any content beyond its parents — nothing hand-merged, no generated region altered by the merge. Both second parents lie on main (fdb2669 is an ancestor of a9fb83e, which is an ancestor of origin/main 17bd318); a9fb83e is the PR's base sha and the merge base at the head. Interdiff: git diff 3875ae677 7459968a6 against git diff a9fb83ef0 755acf300, hunk headers and index lines normalised, differs in 97 lines, all of them (i) the two entry sentences, (ii) the two registry regions, (iii) the defaultFilters leg in the MEASURED test — (d). Every other file's patch is byte-identical at both heads.

(d) What moved beyond the two sentences, their mirrors and the merges: ONE thing. Commit 26e6970 also edited packages/metadata-protocol/src/protocol.stored-migration.test.ts (+10/−1): the MEASURED test gains a third stub row deal_grid (an object-grid with a lossless filter and a combinator defaultFilters) and five assertions (outcome rewritten, one TODO at …properties.defaultFilters, the combinator persisted byte-identical). Declared in report 5857520707 (deviations[1]); not named in the review order or in amendment 5857535828. Judged here: additive only (the makeStubEngine call gains the row; no existing assertion moved), test-only, in a file already inside the PR's claimed surface, passing at the head (30/30 here; CI Test Core green), and it is the measurement both corrected sentences rest on for properties.defaultFilters — without it "a re-save through the metadata API is not refused there" is asserted for that door, not measured. Recorded as the one move outside the two sentences; not treated as a failure, for that reason — see ③. Nothing else moved: the changeset note, cli.mdx, every conversion source, the runtime vocabulary and both surface files carry the same patch as at 7459968; packages/cli is untouched (diff --stat against the base empty).

② Semver level

Clause-②: yes with minor for spec and metadata-protocol is still right. .changeset/17321-conversion-todo-channel.md is byte-identical to 7459968: @objectstack/spec: minor, @objectstack/metadata-protocol: minor, the **Clause-②: yes** line, and the PR body's line 2 still declares it. The delta since 7459968 changes no export, type, accept set or door: two reason strings in two SemanticMigration entries and their generated mirror — release text os migrate meta prints — inside a package this PR already grades minor. A one-sentence D3 text correction adds no grade, and nothing in it moves toward major (no removal, no refusal change). The .changeset/17321-record-filter-d2-conversion.md frontmatter is unchanged (@objectstack/spec: minor, #20175's). The LEVEL axis of check-changeset-no-major did not run in CI at this head (Check Changeset step 15 skipped after the deliberate red) — the same standing as at 7459968; the dev reports it exit 0 locally at 755acf3; not re-run here.

③ Boundary flags

Deliberate correction: .changeset/17321-record-filter-d2-conversion.md — each sentence judged at the head (its diff against the base a9fb83e is lines 48–54 only, frontmatter unchanged): (1) "Such a page keeps loading and rendering unchanged" — TRUE for loading (declined sites byte-identical through the stored seam), rendering NOT MEASURED; (2) "its filter door refuses the form: at dataSource.filter on the page's next save" — TRUE, measured (binding row failed, ①(a)); (3) "at a block's properties.filter / properties.defaultFilters only as the component-props gate's advisory finding (os validate) — a re-save through the metadata API is not refused there, measured" — TRUE and now measured for both doors (the round-1 leg; lint probe: an advisory warning on validate, build and lint); "(os validate)" is one of three, imprecise not false; (4) "for a combinator record that refusal no longer renders the combinator as a field ({ field: '$or', … }); it names the combinator and says why no rule spells it" — TRUE at every door (probe: names $or, no field rendering, the "rules only AND" reason); (5) "os migrate meta --stored lists each filter left as stored as a TODO under its row." — TRUE for the set the note's own "What is left exactly as stored" paragraph enumerates; the universal reading has the (b) limit. Needed: the base text's "does not list these rows yet: a row the conversion leaves as stored reports there as already on protocol" is made false by this PR (test 654–685 at the head), and its "is refused at its filter door on its next save" is measured false for the two properties doors; restoring the base would republish two false sentences. The gate: the Check Changeset job (run 36335221987, job 108664683181) fails at step 12 "Reject an empty-frontmatter changeset added by this PR" after steps 8 and 11 (count / require a changeset) succeeded; the check-run carries exactly one file-scoped annotation, path .changeset/17321-record-filter-d2-conversion.md, "exists on the merge base and was not added by this PR … DELIBERATE CORRECTION — … do NOT restore it", plus the generic "Process completed with exit code 1" line and a runner-image notice — no other file. Not a required context: scripts/check-required-contexts.mjs REQUIRED_CONTEXTS (from line 368) holds seven names — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — and its header (lines 260–290) lists Check Changeset among the names ruled to STAY OUT of the required set ("the labeled event republishes the same context as skipped"). By design: check-empty-changeset.mjs lines 120–127 name the DELIBERATE CORRECTION class ("do NOT restore it; get it confirmed on the PR"), line 563 renders that remedy into the annotation, and lines 605–612 state "this gate stays red either way, and staying red is what puts the decision in front of a person instead of routing around it"; pr-automation.yml lines 41–43 trigger the workflow on pull_request [opened, synchronize, reopened, labeled, unlabeled, edited] only — no merge_group — and its own step text (lines 722–731) says "LEAVE THIS CHECK RED … 'Check Changeset' is not one of the required contexts, so its red blocks no merge and the approver merges over it".
Blocking: none.
Non-blocking:

  • The one move outside the two sentences (①(d)): the additive properties.defaultFilters leg in the MEASURED test, commit 26e6970. Sound, disclosed, and the measurement the corrected clause needs; the seat should add it to the claim's recorded surface rather than have it reverted. Read literally, "anything outside the two sentences moved" names it; this review does not fail the PR on it, for the reasons in ①(d).
  • (b): the universal reading of "each filter left as stored" (element-data-source entry line 84 / registry.ts 8095, and the changeset note's last sentence); one-word tightening given in ①(b). Cosmetic beside it: the element-data-source entry says "(os validate)" where the twin says "(os validate, os build, os lint)" — one names an instance, the other the set; both true.
  • The CLI closing line (meta.ts 745–748, the clean && !apply branch): "Stored metadata is already on protocol N — nothing to rewrite" still prints under a ☐ TODO block for a TODO-only run; packages/cli is untouched here and excluded by the claim; carried from 5856719041 for domain:cli (one condition).
  • Retiring the inline-row decline once .objectui-sha carries objectui#10767 — the PM's follow-up, unchanged.
  • Merge risk, new since the round-3 merge: origin/main has moved to 17bd318 (a9fb83e is 14 commits behind it), and git merge-tree --write-tree 755acf300 origin/main now exits 1 with one content conflict, packages/metadata-protocol/src/protocol.ts — main's feat(lint,metadata-protocol,cli)!: RLS read scopes are admitted by the engine judge when authored, at the save door and at os validate / build / lint #20265 (1207baf) and this PR both edit the @objectstack/spec import block at lines 105–107 (the PR adds ConversionTodoNotice to it); the other five PR hunks in that file (4762, 16714–16999) do not overlap main's (5115, 5130). registry.ts (both) auto-merge. GitHub's mergeable read as true / unstable at the start of this review, null / unknown while recomputing, and false / dirty at the end — the PR can no longer be merged as it stands. Not a defect of the delta and not a FAIL criterion; the seat must order one more os-regen-merge.sh pass, which will meet one trivial import-line conflict in protocol.ts, before landing, and CI on that merge commit will be the head to land on.

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 Fixes #17321, the only closing keyword; #20175 and objectui#10767 are plain mentions. The PR is still a draft.

Implemented-by: claude/issue-17321-conversion-todo-channel
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bafa19790e13eeaaa33b63d69c69c54ccd7e2b01

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 .changeset/17321-record-filter-d2-conversion.md tightened, +13/−9), bafa197 (registry.ts mirror regenerated, +8/−5); the PR base is now 2dccb7d, which is also the merge base. Read: PR object, body and labels, the 15 commits, the 20-file patch at both heads, the 35 check-runs polled twice to convergence, the Check Changeset job steps and its three annotations, the Lint & Repo Gates job's 197 steps; at the head: the entry file and its registry.ts region (8080–8180), both changeset notes and the base note, check-empty-changeset.mjs, check-required-contexts.mjs, pr-automation.yml, lint.yml (step 11), authoring-rules.ts, runtime-gate.ts, page.zod.ts, component.zod.ts, conversions/registry.ts (rewrite / decline / TODO reason), ui/filter-rule-array.ts (isRecordForm), protocol.ts (import block, saveMetaItem parse, migrateStoredMetadata outcome logic), stored-migration.ts (formatStoredMigrationReport, pushTodos, storedMigrationClean), cli migrate/meta.ts, protocol.stored-migration.test.ts, and main's own hunks in the seven grounding files the merge touched. Ran: git merge-tree --write-tree of the merge's parents and a diff of that tree against the merge commit's tree; a normalised interdiff of git diff a9fb83ef0 755acf300 against git diff 2dccb7d49 bafa19790; a line-for-line comparison of the entry body against its mirror region; merge-trees of the head against 733822c, origin/main 6a6a17b and the heads of PR #20255 and PR #20286. NOT MEASURED: no local build, test or generator run this round (no worktree; the delta is prose, a merge and a mirror, and CI on this exact sha ran check:migration-registry, the builds and the tests); the objectui renderer; the CLI door end to end; the three-command lint probe (the rule declaration and its callers read from source instead, as at 755acf3); CI job logs (annotations and step conclusions read instead); check-changeset-no-major's LEVEL axis (step 15 skipped after the deliberate red, as before); the derived gate families, not re-run here by rule.

① Derived judgments

(a) The merge 930fae9 (parents 755acf3 and 2dccb7d). git merge-tree --write-tree 755acf300 2dccb7d49, no merge driver, exits 1 with tree dba19f2ca6334f2e97bb59965b7f4757ece1b6b0 and exactly one conflict, packages/metadata-protocol/src/protocol.ts (three stages; registry.ts in spec/conversions and spec/migrations auto-merged). git diff dba19f2ca 665e0af1f (the merge commit's tree) touches one file, 1 insertion and 6 deletions: the conflict markers and both sides' lines at 108–114 become the two-line resolution. So the merge carries main's content and that one resolution and nothing else. The resolution is import type { IObjectQLEngine, IPubSub } from '@objectstack/spec/contracts'; plus import { applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; — main's IObjectQLEngine (3 mentions at the merge commit, the same 3 as on main; #20265's engine judge) and the PR's ConversionTodoNotice (4 mentions, the same 4 as at 755acf3) are both kept with their uses; IPubSub, applyConversionsToStoredItem and ConversionNotice keep theirs (3 each). git grep for conflict markers over the merge tree returns nothing. protocol.ts at the merge commit differs from 755acf3 by main's #20265 hunks plus the one import name (+20/−1) and from main by the PR's own hunks (+63/−9). No generated region was hand-merged: the two registry.ts files auto-merged, and the mirror is judged in (b). check:generated in CI at the head: Lint & Repo Gates step 11 "Migration registry matches its entry files" (pnpm --filter @objectstack/spec check:migration-registry, lint.yml 382–384, gated by the migration_registry family and not skipped here) is success; steps 167 "Hand-written declaration mirrors" and 168 "Published list mirrors" are success; Type Check · source gates, which runs check:generated --reconcile-only (lint.yml 5452–5453), is success; the job has 197 steps and none is failure.

(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:

  • "os migrate meta --stored lists each such filter as a TODO under its row, naming the block and what blocks the rewrite" — TRUE. "each such filter" is the set the same reason has just enumerated as left as stored (a combinator, a null value, $null / $exists, an AST like, an array or object comparand, an AST group, every filter of an inline-row block); every member is either a declined mapping from legacyFilterToRuleArray or the inline-row case, and both branches reach context.reportTodo (conversions/registry.ts 10881–10897) with reason: "On ${block}, this filter ${declined}. Left as stored, it keeps loading unchanged, …" where block is "the type block id" (describeBlock) and declined is the mapping's own blocker or the inline-row sentence. pushTodos (stored-migration.ts 283–287) prints "TODO conversionId: from left as stored at path — reason" under the row in the converting (225), skipped (239) and failed (248) lists, so wherever the row is listed the TODO nests under it. Pinned: test 654–685 (path, block name, "the combinator $or", the nested line in the rendered report).
  • "(a value that is not a record or AST form at all — a bare string or number one of the former z.unknown() doors took — is neither converted nor reported" — TRUE. legacyFilterToRuleArray (10705–10709) maps only isRecordForm(value) (ui/filter-rule-array.ts 99–103: an object, not null, not an array, plain prototype) or Array.isArray(value); anything else leaves mapping undefined, and rewrite returns the holder untouched at 10877 (if (!mapping) return holder;, commented "Not a legacy form … neither converted nor reported") before any emit or reportTodo. "the former z.unknown() doors" is grounded in the same entry (lines 12, 22, 39: the four block doors were z.unknown()) and the twin entry (line 15: defaultFilters "was z.unknown and therefore accepted a bare string").
  • "and a row carrying nothing else reads as already on protocol)" — TRUE, and the qualifier is what makes it exact. protocol.ts 16988: for a non-flow row changed = notices.length > 0; 17001–17004: !changed with no TODOs → outcome: 'canonical', whose declared meaning is "The chain was a no-op — the row is already on protocol. Not itemised." (stored-migration.ts 63–64); 17014–17022: !changed with TODOs → skipped; a row with a notice is pending / rewritten. A silently unconverted bare value contributes no notice and no TODO, so a row carrying nothing else lands in the first branch; the report's closing line "Every row examined is already on protocol N" prints only when no row converts, fails or carries a TODO (271–277), and the CLI's closing line "Stored metadata is already on protocol N — nothing to rewrite" prints on clean && !apply (meta.ts 744–748). The proposed "its row" would have been false for a row that also carries a notice or a TODO elsewhere — the dev's wording is the precise one.
  • "(os validate, os build, os lint)" — TRUE. validateComponentProps is declared tier: 'advisory', commands: ALL, surfaces: CLI_ONLY (authoring-rules.ts 996–1003), ALL = AUTHORING_COMMANDS = ['validate', 'build', 'lint'] (155, 377), CLI_ONLY = ['cli'] (380); the runtime publish gate runs only rules whose surfaces include 'runtime-publish' (runtime-gate.ts 552, 560); validate.ts, compile.ts and lint.ts each call the authoring runner (3 / 4 / 3 references), and main's feat(lint,metadata-protocol,cli)!: RLS read scopes are admitted by the engine judge when authored, at the save door and at os validate / build / lint #20265 hunks in those three files only add an RLS judgeFilter beside it. The parenthesis now matches the twin entry's.
  • "a row whose only finding is such a TODO is reported skipped, and the run's exit code does not change for it" — carried text, TRUE: 17014–17022; storedMigrationClean is pending === 0 && failed === 0 (195–197); meta.ts 724–725 sets exitCode = 1 only on !clean; test 687–699 pins clean on preview and apply with historyRows empty.
    The changeset note's two changed phrases: "(os validate, os build, os lint)" TRUE as above; "lists each such filter left as stored as a TODO under its row." TRUE as the first clause above — "each such filter" refers back to the note's own "What is left exactly as stored" paragraph (lines 37–48), every member of which reports.

(c) Nothing else moved. The normalised interdiff (git diff a9fb83ef0 755acf300 against git diff 2dccb7d49 bafa19790, hunk headers and index lines stripped) is 51 lines and consists of exactly: the note's rewritten tail (the two phrases and the reflow they cause; the base note is blob 4904fede1 at a9fb83e, 2dccb7d and 733822c alike, so the removed lines are identical at both heads), one context line in protocol.ts (main's IObjectQLEngine import, the resolution), the entry's rewritten string lines, and the mirror's. Both heads carry the same 20-file list; .changeset/17321-conversion-todo-channel.md is blob 4c243c987 at both; the PR's patch to conversions/registry.ts is byte-identical at both heads (main's own hunk there is an RLS comment); packages/cli is untouched (diff --stat against the base empty). 68294cc touches the note and the entry only; bafa197 touches registry.ts only, with the entry's own line delta.

② Semver level

Clause-②: yes with minor for spec and metadata-protocol is unchanged and still right. .changeset/17321-conversion-todo-channel.md is byte-identical to 755acf3 (@objectstack/spec: minor, @objectstack/metadata-protocol: minor, the **Clause-②: yes** line); the corrected note's frontmatter is unchanged (@objectstack/spec: minor); the PR body's line 2 still declares Clause-②: yes. The delta changes prose only — a D3 entry reason, its generated mirror, two phrases of a pending note — plus main's merge; no export, type, accept set, door or exit code moves. The LEVEL axis of check-changeset-no-major (Check Changeset step 15) is skipped after the deliberate red, as at 755acf3.

③ Boundary flags

Deliberate correction: .changeset/17321-record-filter-d2-conversion.md — each sentence judged at the head (its diff against the merge base 2dccb7d is lines 48–55 only; frontmatter unchanged): (1) "Such a page keeps loading and rendering unchanged," — TRUE for loading (a declined or non-legacy site returns the holder untouched with no write, and the replay validates nothing); rendering NOT MEASURED; (2) "and its filter door refuses the form: at dataSource.filter on the page's next save;" — TRUE: ElementDataSourceSchema.filter is z.array(ViewFilterRuleSchema, { error }) (page.zod.ts 226) inside a strict object carried by the strict PageComponentSchema (266, 395), and saveMetaItem parses every item through resolveOverlaySchema(type, item).safeParse and refuses on failure with INVALID_METADATA (protocol.ts 16184–16188, 2696); pinned by the deal_room row (test 766–784), unchanged since the PASSed head and green in Test Core here; (3) "at a block's properties.filter / properties.defaultFilters only as the component-props gate's advisory finding (os validate, os build, os lint) — a re-save through the metadata API is not refused there, measured —" — TRUE: properties is z.record(z.string(), z.unknown()) (page.zod.ts 324), defaultFilters is a rule-array door inside ComponentPropsMap (component.zod.ts 2834), and that map is judged only by the advisory, CLI-only rule (①(b)); "measured" holds in the test (deal_desk and deal_grid rewritten with the combinator persisted, 779–802); (4) "and for a combinator record that refusal no longer renders the combinator as a field ({ field: '$or', … }); it names the combinator and says why no rule spells it." — TRUE: carried from the PASSed head with the refusal map and the conversion byte-identical in the interdiff; the TODO pins "the combinator $or" (test 672–673); (5) "os migrate meta --stored lists each such filter left as stored as a TODO under its row." — TRUE (①(b)). Needed: the base text's "does not list these rows yet: a row the conversion leaves as stored reports there as already on protocol" is false at this head (test 654–685: canonical 0, skipped 1, the report never says "already on protocol" beside a TODO), and its "is refused at its filter door on its next save" is measured false for the two properties doors; restoring the base would republish two false sentences. The gate at this head: job 108690300598 fails at step 12 "Reject an empty-frontmatter changeset added by this PR" after steps 8 and 11 succeed, with 13–15 skipped; its check-run carries exactly one file-scoped annotation, path .changeset/17321-record-filter-d2-conversion.md ("exists on the merge base and was not added by this PR … DELIBERATE CORRECTION … do NOT restore it"), plus the generic "Process completed with exit code 1" (.github, line 51) and a runner-image notice — no other file. Not a required context: scripts/check-required-contexts.mjs REQUIRED_CONTEXTS (line 368 onward) holds seven names — Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — and its header at 274–276 names Check Changeset among the names ruled to STAY OUT ("the labeled event republishes the same context as skipped, washing it green"). Red by design: check-empty-changeset.mjs 127 ("DELIBERATE CORRECTION -> do NOT restore it; get it confirmed on the PR"), 551 and 563 (the remedy rendered into the annotation), 605–612 ("this gate stays red either way, and staying red is what puts the decision in front of a person instead of routing around it"); pr-automation.yml 12–14 triggers on pull_request: types: [opened, synchronize, reopened, labeled, unlabeled, edited] only and the file contains no merge_group; its step text at 722–731 says "LEAVE THIS CHECK RED … 'Check Changeset' is not one of the required contexts, so its red blocks no merge and the approver merges over it". The PR carries no skip-changeset label (labels: documentation, size/xl, tests, tooling).
Blocking: none
Non-blocking:

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 Fixes #17321, the only closing keyword; #20175 and objectui#10767 are plain mentions. The PR is still a draft, 15 commits, 20 files, +1034/−140.

Implemented-by: claude/issue-17321-conversion-todo-channel
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Deliberate-correction red, carried to the merge queue (2026-09-27T20:02Z)

domain:spec seat 4 (session_01CiCTczDo7tGhafXjf61dUJ). Check Changeset is red at bafa19790e by design. The seat carries it into the queue on the three conditions its rules require, each read at this head.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 20:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 7b1e4a4 Sep 27, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-17321-conversion-todo-channel branch September 27, 2026 20:24
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants