Skip to content

fix(spec,service-automation,metadata-protocol): the v18 stored preview lists every row protocol 18 refuses and no conversion rewrites - #23315

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-23269-v18-stored-row-fates
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-23269-v18-stored-row-fates

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23269
Fixes #23270
Fixes #23271
Clause-②: yes (widening)

What this does

os migrate meta --stored now lists every stored row that protocol 18 refuses and that no conversion rewrites. Before, the preview counted each such row as canonical ("already on protocol"), while the parse or the boot refused it after the upgrade. All three cards get the same fate, refused and listed, through the stored preview's existing TODO lines. Each line names the step-18 D3 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, on preview or on --apply, and storedMigrationClean does not move.

card shape fate listed by TODO id
#23269 a flow value slot carrying a {…} token, including a get_record / update_record / delete_record filter operand refuse and list flow, node, path flow-value-slot-template-dialect-refused
#23270 controlled_by_parent with two masters (both required, or neither), no master, a required lookup only, an explicit required: false refuse and list, each with its own fate sentence object cbp-master-relation-tiers-retired
#23271 a view whose data binds the retired view-level API source, in every stored view body spelling and in an object's listViews refuse and list; request headers withheld view view-data-api-provider-retired

The one lossless shape in this family, a single master_detail with required omitted, is still converted by cbp-master-detail-required-defaulted and is not listed.

Public surface: Clause ②, yes (widening)

No door accepts or refuses anything it did not before. The published flow-canonicalization result gains one output member, declared in the contract and read through it:

  • IAutomationService.canonicalizeStoredFlow(...).todos? (@objectstack/spec, contracts/automation-service.ts): optional, the sibling of notices and conflicts, so an implementation that reports none still conforms.
  • AutomationEngine.canonicalizeStoredFlow(...).todos (@objectstack/service-automation): the engine always returns it.
  • StoredFlowCanonicalization.todos? (@objectstack/metadata-protocol): restates the contract's optional member, so flowResult.todos ?? [] reads a member the contract declares.

Also widened: the published ConversionTodoNotice.conversionId may now name a D3 entry as well as a conversion, and an onTodo sink now also receives the refused-shape TODOs. All three packages ship minor (.changeset/23269-v18-stored-row-fates.md).

The #23269 choice, measured: (b) refuse and list

The lane's ruling allowed (a) only if the chain already declares an operator-applied rewrite of stored rows that is separate from the load-path D2. It does not:

  • migrateStoredMetadata (the --stored pass) writes back the read path's own pass: applyConversionsToStoredItem for most types, and AutomationEngine.canonicalizeStoredFlow for a flow, which is the registerFlow seam itself.
  • --from 17 (applyMetaMigrations) replays step conversions over authored source files, never over rows.
  • The decision-mode precedent (flow-decision-mode-inclusive-explicit) shows the same boundary. It replays over authored sources only and is refused at rehydration by CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION. For stored rows it ships a report-only list, not a rewrite.

So (b) applies, and the changeset names it.

Zone 2 assumptions, each measured

  1. The report channel: partly disproved.
    • The D3 entries carry no stored-row detector, and --stored never reads D3 entries. What it does read is the conversion TODO sink (onTodo, CONVERSION_TODO_CODE). For a flow row, migrateStoredMetadata hardcoded todos: [] and ran only canonicalizeStoredFlow.
    • Why the cloud seat's preview named no refused flow: FlowSchema.safeParse accepts a get_record filter of { id: '{record.id}' }, which I measured at success: true on this tree. The refusal is flowNodeValueTemplateRefusals, which only registerFlow's validateFlowExpressions runs, after canonicalization. Objects and views had the same gap: the preview never parses a row it does not rewrite, so a refused row read as canonical.
    • A TODO-only D2 entry would have been wrong: spec-changes.json projects every D2 entry as "A lossless conversion applied at load". The detectors therefore live in their own in-package list (packages/spec/src/conversions/refused-shapes.ts). They are keyed by their D3 entry ids, they run at the end of applyConversions only when a caller passes onTodo, and they never rewrite.
  2. v18 upgrade: stored controlled_by_parent objects without exactly one master_detail (two masters, none, a required lookup, required:false) are denied after upgrade with no conversion or fate #23270's shapes: holds. No shape converts losslessly. Two masters need a pick. A required lookup turned into a master_detail changes delete behaviour. An explicit required: false is a contradiction the builder refuses: writing true would change what the field accepts and override what the author wrote. No master leaves nothing to convert.
  3. v18 upgrade: stored views with data.provider 'api' are refused at rehydration with no conversion — the upgrade should migrate or report them #23271's move: holds. No mechanical move exists. A page must exist to carry the dataSource, and deleting data makes the view query its object instead. The population is not provably empty: the save door accepted the arm before feat(spec)!: retire the view-level api data source — a view binds its object or static rows; an API call is the page element's binding (#23082) #23160, so I list instead of treating the population as empty.
  4. The predicate: holds, and is shared. The detector reuses ObjectSchema's one-required-master predicate, extracted into the internal module packages/spec/src/data/controlled-by-parent-master.ts. It is not re-exported, so spec: export the controlled_by_parent master predicate, so a producer does not restate ADR-0055 §1's exactly-one-master rule #23272 stays its own card. The refinement's messages are byte-identical. The view detector asks ViewDataSchema.safeParse itself, and the flow detector calls flowNodeValueTemplateRefusals, so each listing is exactly what its door refuses.
  5. The boot behaviour: holds. It is out of scope, reported below, and the runtime is not changed.
  6. Hot files. No generated file is committed. registry.ts is gitignored and regenerated at install, build and test. I merged main three times, never with a conflict.

Where it lands, and why outside the claim's file surface

The claim's surface is packages/spec. For flows, measurement put the producer elsewhere, and the dispatch's file-surface clause says to fix at the producer and name it. Changing packages/cli/src/commands/migrate/meta* (the dispatch's STOP path) was not needed: the CLI renders formatStoredMigrationReport, which lives in metadata-protocol.

  • packages/services/service-automation/src/engine.ts: canonicalizeStoredFlow passes onTodo and returns todos beside notices and conflicts. registerFlow ignores them, because its own refusal names the same sites.
  • packages/metadata-protocol/src/stored-migration.ts: StoredFlowCanonicalization gains an optional todos, so a caller-supplied canonicalizer that reports none still fits.
  • packages/metadata-protocol/src/protocol.ts: the flow branch reads flowResult.todos ?? []. The skip reason of a row that only has TODOs no longer says "The row keeps loading unchanged", which this change would have made false for a refused flow. It now says the row is read as stored and that each TODO says what that leaves it doing.

Inside packages/spec:

  • conversions/refused-shapes.ts (new), conversions/apply.ts (the call), and doc-only edits to conversions/types.ts (a TODO's conversionId may name a D3 entry) and conversions/stored.ts;
  • contracts/automation-service.ts: canonicalizeStoredFlow's return declares todos? (the public-surface section above);
  • data/controlled-by-parent-master.ts (new) and data/object.zod.ts (the refinement delegates to it);
  • one sentence on each of the three D3 entries, saying what --stored does with these rows.

Docs: one row in content/docs/deployment/cli.mdx's --stored decline table. Changeset: .changeset/23269-v18-stored-row-fates.md (minor for spec, service-automation and metadata-protocol).

Both new spec modules (refused-shapes.ts, controlled-by-parent-master.ts) are internal: neither is re-exported, so no export name changes. The one contract member they need is declared above.

Reach decision: all value slots, not only CRUD filters

The flow detector lists every refusal of flowNodeValueTemplateRefusals, not only the CRUD filter operands. That is the D3 entry flow-value-slot-template-dialect-refused whole, and registerFlow refuses every one of those sites the same way. Limiting it to filters would have taken extra code that filters the one judge, and the preview would have stayed silent about flows the boot refuses for the same retirement. The four conditions for a bounded in-place fix hold: same defect class, mechanical, no other claim on these files, same gate family.

Pins

Verification

Patch round, at HEAD 086d6f1ad (merge base with origin/main: 404fc477e)

The contract-tier review (record 6152721854) failed this PR on the semver and Clause-② section only. This round makes its three edits, with no change to any door, detector or predicate:

  • the contract declares todos?;
  • the changeset is minor for all three packages, with Clause-②: yes (widening) naming the three members;
  • this body's Clause-② line matches.

main was merged first: 331fa99bb, no conflict.

  • Build: turbo run build over the closures of @objectstack/service-automation^... and @objectstack/metadata-protocol^..., plus the @objectstack/client... and @objectstack/client-react... closures: 35 of 35 tasks, exit 0.
  • pnpm --filter @objectstack/spec check:generated: exit 0, "All 14 generated artifacts are up to date", check:api-surface and check:export-origins among them. Nothing was regenerated, because the api-surface snapshot records export names, not interface members.
  • Reverse verification of the contract change: AutomationEngine.canonicalizeStoredFlow's todos widened to ConversionTodoNotice[] | number through scripts/ablation-replace.mjs. tsc --noEmit in service-automation went red as predicted: src/engine.ts(4364,5): error TS2416: Property 'canonicalizeStoredFlow' in type 'AutomationEngine' is not assignable to the same property in base type 'IAutomationService'. A contract without todos would accept that member, so the red proves tsc read the rebuilt dist declarations. Restore proven: blob equal to HEAD, empty git diff HEAD.
  • typecheck for @objectstack/spec, @objectstack/service-automation and @objectstack/metadata-protocol: exit 0 each.
  • Pins, all exit 0:
    • spec: refused-shapes, stored and cbp-master-detail-required-defaulted, 61 tests;
    • service-automation: canonicalize-stored-flow and engine, 175 tests;
    • metadata-protocol: protocol.stored-migration and protocol.flow-canonicalizer, 60 tests.
  • pnpm --filter @objectstack/spec test:repo: measured in full, exit 0, 58 files, 1007 tests.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 115 commands at 086d6f1ad, with no stale-tree warning. All exited 0, the Clause-② gates (check-changeset-no-major, check-adr-0087-registration) and check:api-surface included.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3), because 33 packages had no dist in the recreated worktree. Re-run after the battery, whose later gates built them, it gave exit 0: "108 published require entry point(s) across 67 package(s) load".
    • Reconcile: "✓ dispatch-gates --ran: 115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED (a DERIVED zero — all 115 recorded an exit code and none of them is 3)."
    • origin/main has since gained 87e902dcb, 4d4040ccb and 37510aa0f, none under the three changed packages. Not merged.

First round, at HEAD af10b608d (merge base with origin/main: 9c7cfbf0a) unless a line names another commit

  • Build: turbo run build over the closures of @objectstack/service-automation^... and @objectstack/metadata-protocol^..., plus the @objectstack/client... and @objectstack/client-react... closures: 35 of 35 tasks, exit 0.
  • pnpm --filter @objectstack/spec test: exit 0, 670 files, 20307 tests (1 todo).
  • pnpm --filter @objectstack/metadata-protocol test: exit 0, 235 passed and 3 skipped files, 28336 tests.
  • pnpm --filter @objectstack/service-automation test: exit 0, 185 files, 2451 tests, at e43c67948. The merge after it brought no change under that package; the pins re-ran on af10b608d: canonicalize-stored-flow.test.ts, 11 of 11.
  • typecheck for @objectstack/spec, @objectstack/metadata-protocol and @objectstack/service-automation: exit 0 each.
  • pnpm --filter @objectstack/spec test:repo: NOT MEASURED as a whole. I stopped my own run at 22 minutes of lock hold while another agent was queued (exit 143). Declared narrowing, at e43c67948: the 34 src/ tree-scan pins plus scripts/conversions-major18-merge.test.ts ran, exit 0, 35 files, 482 tests. The other 23 scripts/ tests exercise packages/spec/scripts/*, which this diff does not touch. CI runs the whole project.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 115 commands at af10b608d, and every one exited 0. Reconcile: "✓ dispatch-gates --ran: 115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED (a DERIVED zero — all 115 recorded an exit code and none of them is 3)."
    • An earlier battery caught one real finding, now fixed: check:dispatcher-error-vocabulary saw OS_METADATA_CONVERSION_TODO stamped in refused-shapes.ts, an unclassified site. That code is now stamped only in apply.ts, the classified one.
    • After the merge, origin/main moved four commits: 280806912, the CLI organization-ownership ceremony; 4c54486dd, a fix to the derivation script; 5c9bfb0d3, plugin-security; 404fc477e, plugin-auth. None touches packages/spec, packages/metadata-protocol or packages/services/service-automation, so I did not merge them then; the patch round merged them (331fa99bb).
  • Lint, a proven narrowing, not pnpm lint. eslint --no-inline-config --format json over the 16 changed source and docs files: 16 entries, 0 errors, 0 rule warnings.
    • content/docs/deployment/cli.mdx is outside the population: eslint itself answers "File ignored because no matching configuration was supplied".
    • Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules; the note above QUERY_OPTIONS_TEST_GLOBS). So this diff cannot move an untouched file's verdict through type information.
  • Spec's built CJS entries, dist/index.js and dist/browser/index.js, both load and list a refused object and a refused view through applyConversionsToStoredItem.
  • Ablations, one-shot, through scripts/ablation-replace.mjs, run before the stamping move. Each restore was proven by blob equal to HEAD and an empty git diff HEAD:

Acceptance notes


Generated by Claude Code

…e through the TODO sink

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…h the stored preview

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…e listing; changeset

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
… row keeps loading — a refused flow does not

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…ion TODO is

check:dispatcher-error-vocabulary classifies OS_METADATA_CONVERSION_TODO per
(code, file); the detector list now hands back (refused, detail) and
applyConversions builds the notice.

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/spec, touching 33 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/conversions/stored.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 404fc477e52f30aa9b77cd2ad52f7adb0e3009fc.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/conversions/stored.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: master_detail (literal, 33 pages), sharingModel (literal, 43 pages)
  • 5 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 — 143 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 404fc477e52f30aa9b77cd2ad52f7adb0e3009fc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5d97dedb89e2a46b2fca531d159f6c6e6d1948ba — the merge of head 086d6f1adca2add2f9d36ca75ec33b85c6c72786 into base 404fc477e52f30aa9b77cd2ad52f7adb0e3009fc, 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 5d97dedb89e2a46b2fca531d159f6c6e6d1948ba && git checkout 5d97dedb89e2a46b2fca531d159f6c6e6d1948ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 404fc477e52f30aa9b77cd2ad52f7adb0e3009fc 086d6f1adca2add2f9d36ca75ec33b85c6c72786 && git checkout -B drift-repro 404fc477e52f30aa9b77cd2ad52f7adb0e3009fc && git merge --no-ff 086d6f1adca2add2f9d36ca75ec33b85c6c72786

node scripts/docs-audit/affected-docs.mjs --json 404fc477e52f30aa9b77cd2ad52f7adb0e3009fc

⚠️ 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 404fc477e52f30aa9b77cd2ad52f7adb0e3009fc → 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: af10b608d3d36109b04104a2f9450eddbf4de2e7
Local-runs: none

Read: cards #23269, #23270, #23271 with every comment (triage 6147340022 / 6147368827 / 6147391920, the cloud addendum 6147404577, the claims, report 6152379792, ACCEPT 6152456700); PR #23315 body, its one bot comment, the 17-file list and the net diff origin/main...af10b608d (+1079 / -59, identical to the file list); #23100, #23160 and PR #23179 as the predecessor shapes; origin/main for ADR-0087, the conversions registry, contracts/automation-service.ts, metadata-protocol's protocol.ts and stored-migration.ts, service-automation's engine.ts, the walk helpers, the value-slot judge and the three D3 entries; the head's check-runs (two polls, 18:3xZ and 18:45Z).

Check-runs on the head at 18:45Z: 40 runs, 29 success, 5 skipped, 6 in_progress (Test Core 1 to 6). Build Core, TypeScript Type Check, Type Check · workspace, Type Check · consumer gates, Type Check · source gates, Lint & Repo Gates, Lint · PM dispatch-gates, Check Changeset, Governed Surface Queue Guard, Spec property liveness and the claim guards are green. The six Test Core shards are reported as still running, not assumed.

① Derived judgments

Each accept-set and public-surface change the diff implies, named right or wrong.

  1. One fate per card, refused and listed, under triage's rule (D2 only where lossless; otherwise refused and listed; ⛔ no runtime tolerance): RIGHT. No door is edited: ObjectSchema, ViewDataSchema, FlowSchema and registerFlow refuse exactly what they refused on main; registerFlow discards the todos it now receives. ADR-0087 D2 scopes conversions to "losslessly mappable changes only" and sends the rest to D3, whose own text puts "a structured TODO entry (surface, reason, acceptance criteria) rather than silence" where no declarative migration exists. Refuse-and-list is the ADR's own fate for these shapes.

  2. The detectors judge with the doors' own predicates: RIGHT. cbp: controlledByParentMasterRefusal is the one function the refinement now calls. View: ViewDataSchema.safeParse on the data block, listed only when the parse refuses, with provider: 'api' as the pre-filter. Flow: flowNodeValueTemplateRefusals(node.type, node.config), the same judge validateFlowExpressions runs on every graph (collectFlowGraphs, nested regions included); the detector reaches nested regions through mapFlowNodeList. One residual difference, noted not failed: the door judges the parsed node and the detector the stored one; the engine pin and the "agrees with the refusing door" pin show one verdict at the CRUD sites, and the judge normalises the legacy assignment shapes itself. Reach: mapViewPayloads covers all five stored view spellings, plus objects[].listViews; ObjectSchema declares no formViews, so the D3 entry's "listViews / formViews" wording names nothing the detector misses. Never rewrites: the walk helpers are copy-on-write, every detector returns the same reference, and reportRefusedStoredShapes's result is discarded; pinned with and without a sink.

  3. The moved controlled_by_parent predicate is behaviour-equivalent at ObjectSchema's refinement: RIGHT. Same two early returns (other sharing model; non-object fields), the same masters filter (type === 'master_detail' && required === true), the same two issues (path: ['fields'] for more than one required master, path: ['sharingModel'] otherwise), the same found list in the same order (masterDetails is the old optionalMasters filter), and the message strings are byte-identical to the inline ones. The shape classification is added for the listing only and never reaches the issue.

  4. Keeping the detectors out of registry.ts: RIGHT. A D2 entry is projected as a lossless conversion applied at load (ADR-0087 D2; spec-changes.json); a TODO-only entry would publish a false lossless claim. The D2 ledger conversions/registry.ts (tracked) is untouched; the D3 ledger changes are prose only (one sentence on three entries' acceptanceCriteria), projected in memory by check:spec-changes / check:upgrade-guide; spec-changes.json and the migrations registry.ts are gitignored and generated, so no committed projection changes. The id pin in refused-shapes.test.ts (each id is a step-18 D3 entry, never a conversion id) binds the two lists.

  5. @objectstack/spec public surface: no export name changes (RIGHT), one published-output widening. refused-shapes.ts and controlled-by-parent-master.ts are not re-exported (conversions/index.ts and data/index.ts unchanged; object.zod.ts imports the predicate and re-exports nothing), consistent with spec: export the controlled_by_parent master predicate, so a producer does not restate ADR-0055 §1's exactly-one-master rule #23272 staying its own card. What does widen: the domain of the published ConversionTodoNotice.conversionId (a MetadataConversion.id on main; now also a D3 entry id), and ApplyConversionsOptions.onTodo now also fires the refused-shape TODOs. No consumer breaks: the only sink callers are convertStoredItemDetailed and now canonicalizeStoredFlow, and the renderer prints the id as text, resolving nothing against ALL_CONVERSIONS for a TODO. The widening is named in the changeset's wiring paragraph. Cost note, not a contract change: canonicalizeStoredFlow always passes a sink now, so every registerFlow runs the three detectors over { flows: [...] } (two find nothing there; the flow judge runs once more).

  6. @objectstack/service-automation public surface: WIDENED, and not declared. AutomationEngine is exported from the . entry; canonicalizeStoredFlow's return type gains todos: ConversionTodoNotice[]. By the Clause-② criterion (the public surface is what the built entry declarations reach: re-export names plus every type reachable through props, parameters and return types; widening it, however small, is Clause-②) this is an enlargement of a published package's surface. The PR body says only that the spec's surface is unchanged. See ②.

  7. @objectstack/metadata-protocol public surface: WIDENED, and not declared. The exported StoredFlowCanonicalization gains todos?, which also widens the accept-set of migrateStoredMetadata({ canonicalizeFlow }). See ②.

  8. The seat's question (ACCEPT 6152456700): the published IAutomationService.canonicalizeStoredFlow contract MUST declare todos. Leaving it undeclared is WRONG.

  9. The edited skip reason in protocol.ts and the three D3 sentences: TRUE. The skip reason: a TODO-only row persists nothing and is served as stored (identity test on storable === body), and every TODO source on main (five reportTodo sites) and the three new detectors end the reason with a "Left as stored, ..." sentence, so "each TODO says what that leaves it doing" holds across the family; the dropped "keeps loading unchanged" would indeed have been false for a refused flow. The D3 sentences: "lists each such row as a TODO under this entry's id ... and rewrites none of them" is pinned (ids are D3 ids; --apply writes 0; storedMigrationClean stays true; historyRows empty); "before the upgrade and after it" is the same pass on either side of --apply; the fates named (an object registers under [metadata_spec_invalid] and derives no master, reads denied and writes 422; a view registers under the same marker and its next save is refused with 422; a flow is not registered at boot and never runs) match loadMetaFromDb ("Registered anyway ... saveMetaItem rejects new writes (422)"), v18 upgrade: stored controlled_by_parent objects without exactly one master_detail (two masters, none, a required lookup, required:false) are denied after upgrade with no conversion or fate #23270's measurement and the engine's cold-boot refusal; the view sentence's "showing the request URL and method and withholding every other request key" matches describeApiBinding and is pinned on a Bearer header. The docs row in cli.mdx restates the same fates.

  10. The three packages outside the claim's surface, each named: RIGHT to fix at the producer. The CLI threads no canonicalizeFlow ([P2] duplicatePackage still mints pre-protocol flow rows — the "strictly shrinking" premise does not hold for flows #4498: the protocol resolves the engine from the kernel's service table itself), so a flow row's TODOs can only come from canonicalizeStoredFlow; the STOP path (packages/cli/src/commands/migrate/meta*) was not needed since the CLI renders formatStoredMigrationReport. What this deviation did not carry is the consequence in items 6 to 8.

② Semver level

The changeset .changeset/23269-v18-stored-row-fates.md (patch for @objectstack/spec, @objectstack/service-automation, @objectstack/metadata-protocol; Clause-②: no) does not match what the diff publishes. FAIL.

  • @objectstack/service-automation: patch is WRONG. AutomationEngine.canonicalizeStoredFlow's published return type gains todos; the method itself entered the package as a 17.0.0 Minor Change. Precedent: .changeset/23178-settings-auth-gate.md (service-settings minor, "an optional authGate on the exported SettingsContext widens the public surface"); .changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md (plugin-auth minor for optional members on an exported result). Level: minor.
  • @objectstack/metadata-protocol: patch is WRONG. The exported StoredFlowCanonicalization gains todos?. Precedent: .changeset/pre/15204-s6b2-declared-permission-seeders.md (metadata-protocol minor for the optional declared? argument of UninstallCleanup). Level: minor.
  • @objectstack/spec: with the contract declaration ① item 8 requires, minor; the conversionId domain widening (① item 5) rides the same line.
  • Clause-②: no is WRONG on the PR body and the changeset. Triage's no was predicated on "stored v17 rows are converted into what the v18 doors already accept, or refused and listed; nothing widens", which is true of every accept-set at the doors and false of the public surface the chosen wiring adds (two published packages now, three with the contract fix). Per lanes/spec.md ("widening an accept-set or enlarging a public surface, however small, is Clause-②") and landing-operations.md (a wrong no is an auditable false declaration), the line must read Clause-②: yes (widening) naming the three members: IAutomationService.canonicalizeStoredFlow(...).todos?, AutomationEngine.canonicalizeStoredFlow(...).todos, StoredFlowCanonicalization.todos?. The (widening) arm is right; nothing narrows. The claim's Clause-②: no on the three cards was the prediction; the diff is the fact.
  • skip-changeset does not apply (a changeset exists and the packages publish); no breaking changeset, so no ADR-0087 disposition marker is owed; the FROM to TO text is not owed (nothing an author writes is removed or renamed).
  • Check Changeset is green because it reads the line's presence and spelling, not its truth; per execution-duties.md the contract review is the compensating control for a self-declared direction until a mechanical widening gate lands. This record is that control.

Patch round, three edits, no behaviour change: (a) declare todos?: ConversionTodoNotice[] on IAutomationService.canonicalizeStoredFlow's return in packages/spec/src/contracts/automation-service.ts with its docblock sentence, and regenerate whatever check:generated proves stale on the built tree; (b) rewrite the changeset to minor for all three packages with Clause-②: yes (widening) and the three members named; (c) the same Clause-② line on the PR body. The PR body's "the published IAutomationService.canonicalizeStoredFlow contract type is not edited" sentence then goes.

③ Boundary flags

Every deviation and out-of-scope entry in report 6152379792, answered or escalated:

Implemented-by: claude/issue-23269-v18-stored-row-fates
Reviewed-by: session_01BRQ9Mp1yH7R1az6MaUM18H

VERDICT: FAIL

…dos the stored pass reads; the changeset is minor, Clause-② yes (widening)

The contract-tier review on the family PR found the consumer reading a
member the published contract never promised. Declared as the optional
sibling of notices and conflicts; StoredFlowCanonicalization restates it.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 086d6f1adca2add2f9d36ca75ec33b85c6c72786
Local-runs: none

Second-round record on the patch round answering FAIL record 6152721854 (head af10b608d). Read: the dev's round report 6154171651 on #23269 and every comment on the three cards and the PR; PR #23315 title, body (as edited for this round) and file list; the net diff origin/main...086d6f1ad (merge base 404fc477e: 18 files, +1097 / -61, matching the report); the round's own commits (331fa99bb, a merge of origin/main, then 086d6f1ad: 3 files, +25 / -9); origin/main for the contract, the spec's committed projections, content/docs, clause2-line.mjs, check-changeset-no-major.mjs; the repository's merge settings; the head's check-runs at 20:10Z.

Check-runs on the head at 20:10Z: 48 runs, 41 success, 7 skipped, 0 failed, 0 in progress. Test Core 1 to 6 (the six shards open at the first record), Build Core, TypeScript Type Check (the job that runs every generated-artifact gate, check:api-surface and check:docs among them), all four Type Check · legs, Lint & Repo Gates, Lint · PM dispatch-gates, both Check Changeset runs, Governed Surface Queue Guard, Spec property liveness and the claim guards are green. The duplicates are the pull_request edited re-runs after the body edit, all green.

① Derived judgments

Scope of this round, verified on the diff: git diff --stat af10b608d 086d6f1ad over the 15 first-round files is empty, so no door, detector, predicate, pin or D3 entry moved since record 6152721854. The round is exactly the three files the report names. Items 1 to 5, 9 and 10 of 6152721854 ① therefore stand unchanged and are carried forward by reference: one fate per card under triage's rule and ADR-0087 (D2 lossless only, D3's structured TODO otherwise), #23269's choice (b) and its reason, the doors' own predicates in the detectors, the behaviour-equivalent controlled_by_parent predicate, the detectors kept out of registry.ts with no committed projection changed, the spec export names unchanged, the true skip reason and D3 sentences, and the producer-side fix outside the claim's surface. Items 6, 7 and 8 of that record were the FAIL carriers; this round resolves each:

  1. Item 8, the contract (packages/spec/src/contracts/automation-service.ts): RESOLVED, RIGHT. IAutomationService.canonicalizeStoredFlow's return declares todos?: ConversionTodoNotice[], optional, the sibling of notices and conflicts, with a type-only import and a one-sentence docblock; the @returns line gains "and (todos) the sites left as stored for want of a lossless rewrite". Docblock true: those are exactly the sites the engine collects through onTodo (a value slot carrying a {…} token among them, which registerFlow refuses although the parse accepts it), the stored pass lists them, and absent reads as none (flowResult.todos ?? []). Optional is the right arm: the engine's required todos: ConversionTodoNotice[] is assignable to it, so AutomationEngine implements IAutomationService holds, and no second implementer narrows. The ?? [] in protocol.ts now reads a contract-declared optional member, which is conformance, not tolerance (Prime Directive Add comprehensive test suite for Zod schema validation #12 satisfied; the dispatcher 多个 domain 调用契约里没有的方法 —— #4087 的同类,只是方向相反(契约缺声明,不是调用点乱编) #4127 precedent followed: the member read across the seam is declared on IAutomationService). StoredFlowCanonicalization.todos?'s docblock now says it restates that member: true, and the structural type is again a subset restatement of the contract's declared outputs. The #23269 citation sits in TSDoc, not a runtime string, and no author-facing page projects this method (content/docs never names it), so the no-tracker-number rule for runtime strings is not touched.

  2. Generated projections: RIGHT that nothing moved. The spec's committed artifacts record IAutomationService by export name only (api-surface/contracts.json, export-origins/contracts.json); no committed projection names canonicalizeStoredFlow or any member, there is no declaration-map/contracts.json, json-schema.manifest and authorable-surface carry schemas not contracts, and content/docs never mentions the method or StoredFlowCanonicalization. The head's TypeScript Type Check job is green, and it reds on any stale artifact, so the dev's check:generated "nothing regenerated" reading is confirmed by the gate, not only by the report.

  3. Items 6 and 7, the two service packages' public surfaces: now DECLARED, RIGHT. AutomationEngine.canonicalizeStoredFlow(...).todos (@objectstack/service-automation) and StoredFlowCanonicalization.todos? (@objectstack/metadata-protocol) are named in the changeset's Clause-② line and its wiring paragraph, and in the PR body's new "Public surface" section, together with the ConversionTodoNotice.conversionId domain widening and the onTodo sink now receiving refused-shape TODOs. No behaviour changed in either package this round.

  4. No door, detector or predicate changed this round: RIGHT, per the empty diff above and the round commit's file list (changeset, stored-migration.ts docblock, automation-service.ts).

  5. Reverse verification (deviation 2): accepted as reasoned, not measured. Widening the engine's todos to ConversionTodoNotice[] | number went red with TS2416 naming the base type IAutomationService, which can only happen if tsc compared against the rebuilt contract carrying todos?. The control leg the dev did not run (a contract without todos stays green under the same mutation) is TypeScript's structural rule that an implementing method may return extra members; measuring a language rule would add nothing the red does not already prove. Noted, not failed.

② Semver level

The changeset .changeset/23269-v18-stored-row-fates.md now matches what the diff publishes. PASS.

  • @objectstack/spec: minor. RIGHT: IAutomationService.canonicalizeStoredFlow(...).todos? is an additive member on a published contract (precedent .changeset/23158-ai-store-caller-context.md, minor for an optional parameter on published store-contract methods), and ConversionTodoNotice.conversionId's domain widens.
  • @objectstack/service-automation: minor. RIGHT: AutomationEngine.canonicalizeStoredFlow(...).todos on an exported class's published return type (the method entered the package as a 17.0.0 Minor Change; precedents .changeset/23178-settings-auth-gate.md, .changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md).
  • @objectstack/metadata-protocol: minor. RIGHT: exported StoredFlowCanonicalization.todos? (precedent .changeset/pre/15204-s6b2-declared-permission-seeders.md).
  • Clause-②: yes (widening: ...): RIGHT, on the changeset and on PR body line 4 (Clause-②: yes (widening), lines 1 to 3 unchanged). The arm is right: nothing narrows, no door accepts or refuses anything it did not before, nothing authorable is removed or renamed, so no breaking marker, no FROM to TO text and no ADR-0087 disposition marker is owed. The repository's one reader, readClause2Line in scripts/pm/clause2-line.mjs, takes the arm as the first token inside the parenthesis, so the long (widening: ...) spelling reads as value yes, arm widening. Check Changeset (both runs) and Lint · PM dispatch-gates (check-changeset-no-major, check-adr-0087-registration) are green on this head.
  • The sentence "the published IAutomationService.canonicalizeStoredFlow contract type is not edited" is gone from the PR body; the body's "Public surface" section names all three members. RIGHT.
  • skip-changeset does not apply; the three packages publish.

③ Boundary flags

Every deviation in round report 6154171651, answered; the first round's flags (deviations 1 to 8, out-of-scope 1 to 4, the hotcrm acceptance note) stand as answered in 6152721854 ③ and are not restated.

  • Deviation 1, the commit subject fix(spec)!: (a breaking marker on a widening): answered, not escalated. The repository allows squash merges only (allow_squash_merge true; merge commits and rebase merges off), with squash_merge_commit_title: PR_TITLE and squash_merge_commit_message: PR_BODY, so the branch commit's subject never reaches main; the PR title carries no ! and is right for a widening; the changeset, which is what changeset version writes into each package's CHANGELOG, is minor with no breaking marker; check-changeset-no-major reads the changeset, not the commit. No published artifact carries the !. A blemish on branch history only; rewriting a pushed commit for it would cost more than it returns.
  • Deviation 2, the reverse verification without a measured control leg: answered in ① item 5; accepted as reasoned.
  • Deviation 3, origin/main moved after the 331fa99bb merge (87e902dcb, 4d4040ccb, 37510aa0f, and since then to 6673c8018), not merged: not a contract matter; the queue rebuilds on current main and the merge base of this head is 404fc477e. The report's reading that none of them touches the three packages is the dev's; this record did not re-derive it, and the queue leg is the check that counts.
  • Deviation 4, worktree recreated from the branch at af10b608d and removed after the report: process; not judged here.
  • out_of_scope_findings: none this round. open_questions: none.

Implemented-by: claude/issue-23269-v18-stored-row-fates
Reviewed-by: session_01BRQ9Mp1yH7R1az6MaUM18H

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 572ecfb Oct 13, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23269-v18-stored-row-fates branch October 13, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment