Skip to content

fix(spec,service-automation): the v18 stored preview lists page components on the retired API source and every refusal registerFlow makes after the parse - #23356

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-23317-stored-preview-gaps
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-23317-stored-preview-gaps

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23317
Clause-②: yes (widening)

What this does

os migrate meta --stored listed two families of stored rows as already on protocol while a v18 door refuses them. Both are now refused and listed, through the preview's existing TODO lines. Nothing is rewritten, on preview or on --apply, and storedMigrationClean does not move. No door accepts or refuses anything it did not before.

item shape listed by TODO id
1 a page component whose data prop binds the retired view-level API source: object-grid, object-map, object-gantt, object-tree page, component and position; request headers withheld view-data-api-provider-retired (the existing D3 entry, its scope extended)
2 every site registerFlow refuses in a stored flow the parse accepts: a text slot's template hole, a decision's mode, a malformed node or edge predicate, a plugin node's undeclared config key, an api flow with no secret flow and site, in the check's own words registerFlow (the refusing door: these sites have no step-18 entry of their own)

Public surface: Clause ②, yes (widening)

The card's prediction was Clause-②: no (the preview lists more, no door accepts more). That holds for every door. The diff also widens published outputs, the same class contract review record 6152721854 named for PR #23315, so this line is yes (widening), and the changeset is minor for both packages:

  • ConversionTodoNotice.conversionId (@objectstack/spec): may now name registerFlow, the refusing door, for a site no conversion and no D3 entry covers. Declared in its docblock.
  • IAutomationService.canonicalizeStoredFlow(...).todos? (@objectstack/spec) and AutomationEngine.canonicalizeStoredFlow(...).todos (@objectstack/service-automation): now also carry each site flow registration refuses. Declared in the contract's docblock. No type changes.
  • The onTodo sink of applyConversions (@objectstack/spec) now also receives a page component bound to the retired API source, under the existing D3 id.

Nothing narrows. No export name changes: check:api-surface is green and nothing was regenerated.

Zone 2 assumptions, each measured

  1. The components: partly disproved.
    • There are four, not two. ComponentPropsMap rows whose data is ViewDataSchema: object-grid, object-map, object-gantt, object-tree. object-kanban, object-calendar and object-timeline take a row array under data and refuse a provider object for its shape, never with this retirement's prescription.
    • A stored page carrying one is not refused at rehydration. PageSchema.safeParse and getMetadataTypeSchema('page') both accept the page, because PageComponent.properties is an open record. The door that refuses the binding is the component-props gate (validateComponentProps in @objectstack/lint). It is declared surfaces: CLI_ONLY, so os validate, os build and os lint refuse it in source, and the save door and the boot do not.
    • So the TODO's fate sentence says what is true: the page still loads and saves, and the CLI refuses the binding in source. The ruling's listing stands as written.
    • Extending the existing D3 entry is the truthful record, not a sibling entry. It is one retirement: the same arm of the same schema, under the same ruling. The entry's surface / replacement / reason / acceptanceCriteria now name the four components and the component-props gate.
    • The judge is the door's own predicate. Each stored component is judged by its own ComponentPropsMap row's data member, the one the component-props gate parses. It is listed only when that row refuses the binding with the prescription ViewDataSchema gives. No list of types is kept in step: a pin derives the four from ComponentPropsMap and holds the D3 entry's and the detector's surface text to them.
  2. The whole validation: holds, with one deviation from the Zone 3 route.
    • canonicalizeStoredFlow was registerFlow's first half: convert, parse, cycle check, region validation. The four registration checks (validateNodeConfigKeys, validateDecisionModes, validateFlowExpressions, validateApiTriggerSecret) ran only inside registerFlow, and each threw.
    • Measured on a probe at base 688a39d77. Canonicalize passed, and registerFlow refused: a decision mode: 'bogus', a malformed node predicate, a braced edge predicate, an api flow with no secret, and a notify title on the single-brace dialect or with an unknown formatter. A builtin's undeclared key and spec(automation): the #21898 token carve-out (builtinValueJudged / carriesInterpolationToken) outlived every interpolation — a built-in config value carrying a {token} registers past its type, then fails every run #23260's value arm (get_record limit: '{x}') are refused inside FlowSchema.parse. So canonicalizeStoredFlow throws them, and the stored pass already reports the row failed with the parse's words.
    • Deviation from Zone 3: the checks cannot run from a module-scope function. They read the live engine: the action registry (plugin node configSchema), the trigger binding and its credential channel (resolveSecret), and the object-schema resolver. So the shared list is an instance method.
    • Each check now returns its refusal (the exact message it threw, plus its sites) instead of throwing. registrationChecks() is the one ordered list. registerFlow iterates it and throws the first refusal, so the same flows are refused with byte-identical messages. canonicalizeStoredFlow runs the same list and lists every site. A check added to that list later is listed with no second change.
    • The listing never throws: a check that throws instead of refusing is listed as one site at the flow. It runs only when todos is first read, once. So the save door, a package duplication and the sync's held-flow comparison (holdsFlowDefinition in plugin.ts), which read parsed / storable only, do no more work than before.
    • The schema-aware advisory pass (logged, never thrown) runs for registration only, so the doors that canonicalize log nothing new.
  3. No double listing: holds, and dedupe is needed. A value-slot {…} site is refused by both the spec detector (D3 id) and validateFlowExpressions. Registration sites carry stack paths spelled as the conversion walk spells them (flows[0].nodes[1].config.body.nodes[0].config.title), and a site a chain TODO already names is skipped. Ablation A4 below: without the skip, the existing v18 upgrade: stored flows whose get/update/delete_record filter operand is a {…} token are refused at boot with no conversion — every deployment's automations built on the old spelling stop running #23269 engine pin goes red.
  4. The report: holds; the line names the door. A step-18 refused shape keeps its D3 id. A registration refusal has no step-18 entry, so its line reads TODO registerFlow: SOURCE left as stored at PATH — No conversion rewrites it, and registerFlow refuses it: left as stored, the flow is not registered at boot and never runs. Flow NAME, WHERE: the check's message. Here SOURCE, PATH, NAME and WHERE are placeholders. from shows the source text, the key, or the declared value. For the api secret, it shows only a blank string or null, so no secret is printed.

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

The claim's surface was taken whole: refused-shapes.ts and its test, the D3 entry, engine.ts, stored-migration.ts (docblocks only) and one changeset. Outside it:

  • packages/spec/src/conversions/types.ts and packages/spec/src/contracts/automation-service.ts: docblocks only. They declare the widened domain and content above.
  • packages/runtime/src/dispatcher-error-vocabulary.ts: one foreign-vocabulary row. check:dispatcher-error-vocabulary refuses any unclassified site that stamps OS_METADATA_CONVERSION_TODO, and the engine now builds that TODO. The row sits beside the apply.ts row and has the same reasoning. The file is read by the gate and by a test, and it is not in runtime's dist.
  • content/docs/deployment/cli.mdx: the --stored table. Its row said each TODO names a step-18 entry and that the parse or the boot refuses the row, and both would have become false. It now lists both families, each with its fate.
  • packages/services/service-automation/src/builtin/config-unknown-keys.test.ts: one pin called the private validateNodeConfigKeys and expected a throw. It now reads the returned refusal.
  • packages/metadata-protocol/src/protocol.stored-migration.test.ts: one preview pin for a page row.
  • Not touched: packages/metadata-protocol/src/protocol.ts (fenced for PR fix(metadata-protocol): an environment kernel that hydrates its own store writes a published body through to its registry, so the security catalog serves it at once #23345). The stored pass already reads flowResult.todos ?? [], so no edit there was needed.

Pins

  • Item 1 (refused-shapes.test.ts, 14 new):
    • each of the four components is listed once at pages[0].regions[0].components[0].properties.data, naming the page, the component and its position, with the fate and the prescription; the row is not rewritten;
    • reach: a component nested in a page:tabs panel, and one in a slot;
    • no credential: headers are withheld;
    • negative controls, none listed: the object and value providers, object-kanban (a row-array data), a custom type with no props row, and the governed dataSource element binding;
    • door agreement, both directions: the listed set equals the props rows whose data refuses the binding with the prescription;
    • the fate holds: PageSchema accepts the page, and each row refuses the binding;
    • the D3 entry and the detector name the four.
    • metadata-protocol: the preview lists the page row skipped with its TODO, without the header. An object provider page counts as canonical. --apply writes nothing.
  • Item 2 (canonicalize-stored-flow.test.ts, 23 new):
    • eight refused flows, one per check (two text-slot shapes, a text slot in a loop body, a decision mode, a node predicate, an edge predicate, a plugin node's undeclared key, an api flow with no secret). Each is listed under registerFlow at its site, in the check's words, and registerFlow refuses it with the same words;
    • negative controls: the same eight on protocol list nothing and register;
    • door agreement, both directions: over the corpus, a flow is listed exactly when registerFlow refuses it;
    • several checks in one flow are all listed, in registerFlow's order, and registerFlow still throws the first;
    • dedupe: a braced filter operand plus a single-brace title gives one D3 TODO and one registerFlow TODO;
    • a parse refusal (spec(automation): the #21898 token carve-out (builtinValueJudged / carriesInterpolationToken) outlived every interpolation — a built-in config value carrying a {token} registers past its type, then fails every run #23260's value arm) throws from canonicalizeStoredFlow as from registerFlow;
    • nothing is rewritten: storable is the input reference;
    • laziness: reading parsed / storable / conflicts runs no check, and todos runs them once;
    • no new logs: the advisory pass logs at registerFlow and not at canonicalizeStoredFlow.

Verification

At HEAD ac768f1d5 unless a line names another commit. origin/main was merged at ac768f1d5: 5 commits, none under packages/spec. service-automation's notify-node.ts changed there, so that suite re-ran after the merge.

  • Build: the closures of @objectstack/service-automation^..., @objectstack/metadata-protocol^... and @objectstack/runtime^... (turbo, 30 of 30 tasks), then @objectstack/spec again; every VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date". No tracked file moved.
  • typecheck, exit 0 each: @objectstack/spec (test layer held), @objectstack/service-automation (after the merge), @objectstack/metadata-protocol, @objectstack/runtime.
  • Tests:
    • @objectstack/service-automation, the whole suite after the merge: 185 files, 2481 tests, exit 0.
    • @objectstack/spec local project in four locked runs: 102 files / 2735 tests, 170 / 5918, 310 / 9524 (+1 todo), and 88 / 2245. That is 670 files, the whole project (729 test files minus the 59 repo ones). All passed, at 706df2da0; no spec file changed after it.
    • @objectstack/spec test:repo: 59 files, 1011 tests, exit 0, at 706df2da0.
    • @objectstack/metadata-protocol: protocol.stored-migration, protocol.flow-canonicalizer and protocol.save-flow-canonicalization, 80 tests, exit 0. This is a declared narrowing: the package's code is unchanged (docblocks only), and CI runs the suite.
    • @objectstack/runtime: error-envelope.conformance.test.ts (the test that imports the vocabulary file), 53 tests, exit 0. Also a narrowing: runtime's code is unchanged.
  • Ablations, one-shot, through scripts/ablation-replace.mjs in wrap mode under the lock. Every anchor went 1 to 0, and every restore was proven: blob equal to HEAD, empty git diff HEAD. The spec and engine pins import their subject by relative path, so no dist leg was needed.
  • Lint, a measured narrowing, not pnpm lint:
    • eslint --no-inline-config --format json over the 13 changed files gave 13 entries. 11 were linted, with 0 errors and 0 rule warnings. eslint itself ignores the changeset and cli.mdx ("File ignored").
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot move an untouched file's verdict.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 114 commands at ac768f1d5, with no stale-tree warning. Each ran with its exit code recorded before any pipe.
    • First pass: 112 exited 0. check:skill-examples and check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: an unbuilt client-react, and 30 unbuilt packages).
    • Both exited 0 once turbo run build filled their prerequisites (35 of 35 and 74 of 74 tasks, cached).
    • Reconcile: "✓ dispatch-gates --ran: 114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED (a DERIVED zero — all 114 recorded an exit code and none of them is 3)."
    • On the way, check:dispatcher-error-vocabulary caught one real finding: the engine's TODO stamp was unclassified. The vocabulary row above fixes it.

Serial note: #23316

#23316 (branch claude/issue-23316-refused-flow-runtime-state, no PR yet) edits engine.ts, plugin.ts and contracts/automation-service.ts. git merge-tree --write-tree HEAD origin/claude/issue-23316-refused-flow-runtime-state gave exit 0 and tree 5069a5cc8 (no conflict), at this head and that branch's 2b84fe555.

  • That branch imports a type named FlowRegistrationRefusal into engine.ts, so this branch's internal types are named RegistrationCheckRefusal / RegistrationCheckSite. None of the identifiers this diff adds appears on that branch.
  • This diff does not touch plugin.ts, the cold-boot bind, withdrawFlow, or what registerFlow does after its checks.
  • Whichever of the two PRs reaches the queue second merges main and re-runs the automation suite and the contract gates. A joint typecheck of the merged tree was not measured here.

Acceptance notes

  • The retired API binding still works on a stored page at runtime. The component-props gate is CLI-only by its declared surfaceReason ("Gating rule held off the runtime door pending the crossing discipline … Crossing is its own rollout card"). The save door and the boot accept a page component whose data carries provider: 'api' with headers. At objectui origin/main 72594f29, resolveDataSource still builds an ApiDataSource from such a config (ObjectGantt.tsx routes through it). So a stored page keeps sending the request as stored, headers included, until the row is rewritten. This PR lists those pages and does not change a door. It is reported as a finding.
  • The prescription the component-props gate prints at a component's data says view.data.provider, because it is ViewDataSchema's own text. This is wording only, and its carrier is the retirement's owner.
  • Cost: when a caller reads todos, the stored pass runs registration's checks once per flow row. Nothing else pays for them.

Generated by Claude Code

claude added 10 commits October 13, 2026 23:04
…isterFlow refuses after the parse

registerFlow's registration checks (undeclared plugin config keys, decision
modes, expressions and template holes, the api trigger secret) now return
their refusal with its sites instead of throwing. registerFlow throws the
first refusal, byte-identical, and canonicalizeStoredFlow lists every site as
a TODO under 'registerFlow', so the stored preview reads such a flow as
refused rather than on protocol. A site a conversion TODO already names is
not listed twice.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
…retired view-level API source

The view-data-api-provider-retired detector now also walks every stored page's
components and lists a component whose own props row refuses its data prop
with the retirement's prescription (object-grid, object-map, object-gantt,
object-tree), by page, component and position, with its fate. The D3 entry's
scope names those components.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
…ing, declared where they are read

The conversion TODO's conversionId may name 'registerFlow'; the contract's
todos member says it carries registration's whole verdict; the CLI page's
--stored table lists both new families; and the engine's TODO stamp is
classified beside the conversion pass's own.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
…und to the retired API source

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
…ired API source and registerFlow's refusals

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
…ypes apart from the flow registration verdict

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
…ller that reads todos

The save door, a package duplication and the sync's held-flow comparison read
parsed or storable only, so they pay nothing for the stored preview's listing.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/service-automation, @objectstack/spec, touching 32 documentable anchor(s).

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

  • content/docs/api/error-handling-server.mdx (via safeParse (symbol, a method of interface SafeParseable))
  • content/docs/automation/flows.mdx (via registerFlow (symbol, a method of class AutomationEngine), registerFlow (literal, a string literal in FLOW_REGISTRATION_TODO_ID; a string literal in a comment in ConversionTodoNotice; a string literal in a comment in StoredFlowCanonicalization; a string literal in a comment in canonicalizeStoredFlow))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine), registerFlow (literal, a string literal in FLOW_REGISTRATION_TODO_ID; a string literal in a comment in ConversionTodoNotice; a string literal in a comment in StoredFlowCanonicalization; a string literal in a comment in canonicalizeStoredFlow))
  • content/docs/deployment/cli.mdx (via registerFlow (symbol, a method of class AutomationEngine), registerFlow (literal, a string literal in FLOW_REGISTRATION_TODO_ID; a string literal in a comment in ConversionTodoNotice; a string literal in a comment in StoredFlowCanonicalization; a string literal in a comment in canonicalizeStoredFlow))

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), IAutomationService (symbol, a top-level interface), registerFlow (symbol, a method of class AutomationEngine), registerFlow (literal, a string literal in FLOW_REGISTRATION_TODO_ID; a string literal in a comment in ConversionTodoNotice; a string literal in a comment in StoredFlowCanonicalization; a string literal in a comment in canonicalizeStoredFlow))
  • content/docs/releases/v17/17-4.mdx (via registerFlow (symbol, a method of class AutomationEngine), registerFlow (literal, a string literal in FLOW_REGISTRATION_TODO_ID; a string literal in a comment in ConversionTodoNotice; a string literal in a comment in StoredFlowCanonicalization; a string literal in a comment in canonicalizeStoredFlow))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (symbol, a method of class AutomationEngine), safeParse (symbol, a method of interface SafeParseable), registerFlow (literal, a string literal in FLOW_REGISTRATION_TODO_ID; a string literal in a comment in ConversionTodoNotice; a string literal in a comment in StoredFlowCanonicalization; a string literal in a comment in canonicalizeStoredFlow))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-7.mdx (via IAutomationService (symbol, a top-level interface), registerFlow (symbol, a method of class AutomationEngine), registerFlow (literal, a string literal in FLOW_REGISTRATION_TODO_ID; a string literal in a comment in ConversionTodoNotice; a string literal in a comment in StoredFlowCanonicalization; a string literal in a comment in canonicalizeStoredFlow))

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
  • 1 cross-cutting symbol(s) contributed no route anchor: safeParse (5 routes)
  • 12 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 — 147 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 228357ab69aaaaba6e306b7cce7bb3fdc757ccb7 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 228357ab69aaaaba6e306b7cce7bb3fdc757ccb7

⚠️ 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 228357ab69aaaaba6e306b7cce7bb3fdc757ccb7 → 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: ac768f1d54f1225d9cb6e64b581ca0fe8312774d
Local-runs: none

Read: card #23317 with every comment (triage 6152829172, the unlock 6155083387, the claim 6156647772, the serial note 6157679610, report 6158463913, ACCEPT 6158509652); PR #23356 body, its one bot comment, the 13-file list and the net diff origin/main...ac768f1d5 (+834 / -125, identical to the file list); PR #23315 (merged 572ecfb2e) with records 6152721854 and 6154280528 and its changeset .changeset/23269-v18-stored-row-fates.md; PR #23319 (#23260, merged ca0bdbcca); origin/main for engine.ts (the four checks, registerFlow, deriveTriggerBinding), plugin.ts (holdsFlowDefinition), protocol.ts (the save door at 21889, the stored pass at 22936 to 23010, the rehydration read at 6455 to 6510, the two other canonicalize seams at 25965 and 26212), stored-migration.ts (the report lines), conversions/walk.ts, conversions/refused-shapes.ts, conversions/types.ts, ui/component.zod.ts (ComponentPropsMap), shared/lazy-schema.ts, ui/page.zod.ts, automation/control-flow.zod.ts (FlowGraph, collectFlowGraphs), lint/src/validate-component-props.ts and authoring-rules.ts, runtime/tsup.config.ts, scripts/pm/clause2-line.mjs, the spec lane's Clause-② line; the #23316 branch at 2b84fe555 (git diff origin/main..., 22 files) for engine.ts, plugin.ts and contracts/automation-service.ts; the head's check-runs (four polls, 01:0xZ to 01:14Z).

Check-runs on the head at 01:14Z: 39 runs, 31 success, 5 skipped, 0 failed, 4 in_progress: Test Core 3 to 6. Build Core, Type Check · workspace, Type Check · consumer gates, Type Check · source gates, Type Check · debt ledger, Lint & Repo Gates, Lint · PM dispatch-gates, Lint · ESLint, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Test Core 1/6 and 2/6 and the claim guards are green. The four open shards are reported, not assumed: this record judges the contract, and the landing condition (every check green) is the owning seat's to read once they finish.

① Derived judgments

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

  1. registerFlow refuses exactly what it refused on main, with byte-identical messages, in the same order: RIGHT. Read check by check against origin/main. validateNodeConfigKeys: the per-key line is the same template (node 'ID' (TYPE): unknown config key ... Declared here: ...), now carried as UndeclaredConfigKey.line, scoped with the same graph.scope · prefix, pushed per node in the same node-then-key order the old scoped list transferred in, under the same Flow 'NAME' rejected: N undeclared config key(s). head and tail. validateDecisionModes: • ${at}node 'ID' (decision) at config.mode: ${issue.message} and the same head. validateFlowExpressions: every failures.push became refuse(path, where, message, source), whose line is • ${where}: ${message}\n source: \${source}`with the samewherestrings and the same rawsource values (e.source, slot.source, refusal.source, shapeRefusal.source, blankRefusal.source); the head sentence is unchanged. validateApiTriggerSecret: Flow 'NAME' rejected: ${why}wherewhyis the old literal fromit binds the inboundonward.registerFlowiteratesregistrationChecks({ advisory: true })in the old order (keys, modes, expressions, secret) and throwsnew Error(refusal.message), a plain Errorwithoutcode, as each check threw before. A check that throws rather than refuses still propagates out of registerFlowunchanged (the loop does not catch). Nothing after the checks moves: the only otherregisterFlowhunk swapsthis.canonicalizeStoredFlowforthis.canonicalizeFlow, whose body is the old method's body verbatim (the hunk inserts the signature above an unchanged // ADR-0087 D2 — the runtime load seam` block), so the chain run, parse, cycle check and region validation registration does are what they were.

    • The advisory flag: RIGHT, and it moves no refusal in either direction. On main the schema-aware pass inside check() only calls this.logger.warn (Formula guardrail: cel-js arithmetic silently returns null (double × int + bare identifiers) #1928, logged never thrown); it pushes nothing to failures. advisory: true (registration) keeps that logging; advisory: false (the stored pass) skips only the logger.warn block. No check that threw at registration now only warns, and nothing that warned now throws. Pinned: the stored pass logs nothing while registerFlow warns on the same flow (a start-node condition naming the misspelled field amout).
    • The pins. The two-direction corpus pin (seventeen flows, listed exactly when registerFlow throws), the first-refusal-wins pin and the eight per-case pins hold the refusal SET and the ORDER. The per-case message pins match a regex fragment of the check's words, not the whole string, so byte-identity rests on the diff reading above rather than on a pin. Strong enough for the card's hard line, with that caveat named.
  2. No door moves: RIGHT. FlowSchema, ViewDataSchema, PageSchema, ObjectSchema and the component-props gate are untouched. The three doors that canonicalize without registering read only conflicts, storable and parsed off the result (protocol.ts 21905 to 21925, 25965 to 25983, 26212 to 26217; plugin.ts:352), so the lazy todos accessor never runs there; pinned with a spy. The rehydration read (convertStoredItem, protocol.ts:6455) warns on notices and discards TODOs, so a stored page carrying the binding gains no boot log line. deriveTriggerBinding, which the stored pass now runs, has no logger call. The stored pass keeps storedMigrationClean and --apply writes nothing (pinned on a page row and on the flow rows). One published-shape subtlety, cost only: todos is an enumerable accessor on the returned literal, so a caller that spreads or JSON.stringifys the whole result would run the four checks; none on main does, and the accessor never throws (registrationCheckRefusals catches).

  3. Item 1, the detector is the door's own judge, keyed by the entry id, headers withheld: RIGHT. componentApiBindingRefusal first takes apiBindingRefusal(data) (ViewDataSchema.safeParse, the view judge PR fix(spec,service-automation,metadata-protocol): the v18 stored preview lists every row protocol 18 refuses and no conversion rewrites #23315 landed), then parses the same data with the component's OWN ComponentPropsMap[type].shape.data member, the schema the component-props gate parses, and lists the site only when that member's issues carry the identical prescription string. lazySchema is a Proxy that forwards .shape to the real instance (shared/lazy-schema.ts), so the lookup resolves on the four lazy rows. The listing rides the existing viewDataApiProvider detector, so the TODO's conversionId is view-data-api-provider-retired; from goes through describeApiBinding, which keeps only provider and each request block's url and method ([withheld] otherwise), pinned on a Bearer header. Reach: mapPageComponents walks regions[].components, slots (one component or a list) and every pageComponentSlotPositions() container, panel lists included; the nested-tabs and slot paths pinned are the walker's own spellings.

    • The four components are exactly the rows whose data is ViewDataSchema: RIGHT. component.zod.ts on main carries exactly four data: ViewDataSchema.optional() members, at 6027 (ObjectGridPropsSchema), 8979 (ObjectMapPropsSchema), 9529 (ObjectGanttPropsSchema) and 10105 (ObjectTreePropsSchema). object-kanban / object-calendar / object-timeline take a row array under data and refuse the binding for its shape, with a different message, so the equality judge leaves them out, and the door-agreement pin iterates every ComponentPropsMap key in both directions.
    • The D3 entry's new scope text is TRUE, the fate sentence included. PageComponent.properties is z.record(z.string(), z.unknown()) (page.zod.ts:592), so PageSchema accepts the page; the props rule is surfaces: CLI_ONLY with commands: ALL (authoring-rules.ts:1170-1193; its own header at validate-component-props.ts:111-116 names the three CLI doors and says the runtime publish door does not run it). So "the page still loads and saves, while os validate, os build and os lint refuse the binding in source" is what the code does; the acceptanceCriteria, surface and replacement sentences name the same four components and the same gate.
  4. Item 2, the stored pass lists exactly registerFlow's refusals, from one list, deduped by site: RIGHT. registrationChecks is the one ordered list; registerFlow throws its first refusal and registrationCheckRefusals collects all of them with advisory: false, so a check added to the list is refused and previewed alike (the comment at the call site says so). Site paths are spelled as the conversion walk spells them: storedGraphPath renders FlowGraph.path (['nodes', 1, 'config', 'body']) as flows[0].nodes[1].config.body, and mapFlowNodeList on main spells a nested node ${path}.config.${key}.nodes[i] and a branch ${path}.config.${key}[i].nodes[j], so the two agree at depth. The dedupe keys on that path against the chain's TODOs; the one overlap that exists today is the value-slot site (D3 flow-value-slot-template-dialect-refused and validateFlowExpressions), pinned as one line under the D3 id, and the existing v18 upgrade: stored flows whose get/update/delete_record filter operand is a {…} token are refused at boot with no conversion — every deployment's automations built on the old spelling stop running #23269 "exactly one TODO" engine pin stays green only with the skip (ablation A4).

    • spec(automation): the #21898 token carve-out (builtinValueJudged / carriesInterpolationToken) outlived every interpolation — a built-in config value carrying a {token} registers past its type, then fails every run #23260's value arm (unlock 6155083387): COVERED, and the operator is told. PR fix(spec)!: a builtin config value carrying a {token} is judged for its type at the build doors (#23260) #23319 put the arm inside flowNodeConfigRefusals, which FlowSchema's superRefine runs, so get_record limit: '{x}' is refused by the parse, before any registration check. canonicalizeFlow throws it, canonicalizeStoredFlow throws it (pinned beside registerFlow on /limit/), and the stored pass's existing catch (protocol.ts:22949-22960) records the row failed with the flow does not canonicalize: plus the parse's message, which formatStoredMigrationReport prints under ✗ N row(s) could not be rewritten. Not a TODO, and not silence: a failed row keeps storedMigrationClean false. The changeset and cli.mdx say exactly this.
    • Two residuals, noted not failed. (a) The site index is taken over graph.nodes, the list collectFlowGraphs hands out after dropping a non-record member, while FlowGraph.path indexes the raw list; they differ only for a region with a dropped member, which the region validator refuses inside canonicalizeFlow before the checks run, so no listed path is reachable off by one. (b) The dedupe is by path across ids: a future chain TODO at a site a registration check also refuses would keep the chain's line and drop the registerFlow one. One line per site is the chosen policy; today the only such site is the value slot, where the chain's line is the retirement's own.
  5. @objectstack/spec public surface: no export name changes, two published-output widenings, both declared: RIGHT. refused-shapes.ts's new helper is module-private; conversions/index.ts and ui/index.ts are untouched (check:api-surface green, nothing regenerated). ConversionTodoNotice.conversionId may now be 'registerFlow': declared in its docblock (conversions/types.ts). IAutomationService.canonicalizeStoredFlow(...).todos? now also carries registration refusals: declared in the member's docblock and the @returns line (contracts/automation-service.ts). ApplyConversionsOptions.onTodo now also receives a page-component TODO under the D3 id: named in the changeset, the same declaration route record 6152721854 ① item 5 accepted. No #NNNN reaches a runtime string: the citations sit in TSDoc, and the TODO prose carries none.

  6. @objectstack/service-automation public surface: one widening, declared; one new module export kept off the index: RIGHT. AutomationEngine.canonicalizeStoredFlow(...).todos (an exported class's published return) now also carries registration refusals; the method's docblock says so, and the changeset names it. FLOW_REGISTRATION_TODO_ID is a named export of engine.ts only: src/index.ts re-exports engine.js by name (lines 4 and 70 to 88) and is not in the diff, so the constant is not barrel-reachable. The internal types RegistrationCheckSite / RegistrationCheckRefusal are not exported.

  7. @objectstack/metadata-protocol: docblocks only, and the restated domain is declared: RIGHT. No code in the package changes. StoredMigrationTodo.conversionId's docblock now says what the field has carried since PR fix(spec,service-automation,metadata-protocol): the v18 stored preview lists every row protocol 18 refuses and no conversion rewrites #23315 (a D3 id) plus registerFlow, and StoredFlowCanonicalization.todos? restates the contract member it mirrors. The flattening at protocol.ts:23006-23014 is unchanged and reads flowResult.todos ?? [], a contract-declared optional (record 6154280528 ① item 1). The widened domain reaches this package's published report rows through the carrier in @objectstack/spec, which the changeset names; the docblock is the declaration on this side, and with no feature, improvement or fix in the package's own code no version line is owed here. See ②.

  8. @objectstack/runtime: the ledger row is non-shipping: RIGHT. dispatcher-error-vocabulary.ts is read by scripts/check-dispatcher-error-vocabulary.mjs (its DECLARATION constant) and by one conformance test; tsup.config.ts's entry is src/index.ts, and the only reference under src/ is a comment in http-dispatcher.ts. The row is right in substance: the engine's flowRegistrationTodo stamps code: CONVERSION_TODO_CODE (OS_METADATA_CONVERSION_TODO) on a returned record, never on a throw (registerFlow throws its own plain refusal), which is the foreign-vocabulary verdict the sibling apply.ts row carries. Lint & Repo Gates is green on the head.

  9. content/docs/deployment/cli.mdx, the two --stored rows: TRUE now. formatStoredMigrationReport prints TODO ${conversionId}: ${from} left as stored at ${path} — ${reason} (stored-migration.ts:456), so a registration site prints as TODO registerFlow: ... with the fate sentence leading; a parse refusal prints under the could not be rewritten block; the page fate matches item 3; the old row's "the parse or the boot refuses the row" would have been false for a page, and the edit removes that sentence for the page case only.

  10. The one changed test that called the private validator: RIGHT. config-unknown-keys.test.ts pinned "builtin types judged by the spec are not walked; a plugin type is". The subject's signature changed from throw to return, and the pin now asserts undefined for the builtin set and a returned message matching /undeclared config key/ for the plugin type, the same two facts in the new shape. No assertion was weakened.

  11. Serial with automation: a stored flow the engine refuses at cold boot is withdrawn with a warn, while the metadata read keeps serving it as status: 'active' — after the v18 upgrade, every refused flow reads as running #23316 (2b84fe555): no semantic collision visible from the two diffs. Contract: automation: a stored flow the engine refuses at cold boot is withdrawn with a warn, while the metadata read keeps serving it as status: 'active' — after the v18 upgrade, every refused flow reads as running #23316 adds getFlowRegistration?(name) at line 1001 with a FlowRegistration type import; this PR edits the canonicalizeStoredFlow docblock at 802 to 820; different members, no shared line. Engine: automation: a stored flow the engine refuses at cold boot is withdrawn with a warn, while the metadata read keeps serving it as status: 'active' — after the v18 upgrade, every refused flow reads as running #23316 adds a flowRefusals map, deletes the name from it after this.flows.set(name, parsed) (a line after the checks, which this PR does not touch), deletes it in withdrawFlow, and adds recordFlowRefusal / getFlowRegistration; this PR's engine hunks are the check calls, the two canonicalize methods, the four checks and the new helpers. The one semantic seam is recordFlowRefusal keeping what registerFlow threw (message, and code only when the thrown error carries a string one): this PR keeps the message byte-identical and still throws a plain Error without code, so a recorded refusal reads the same after either merge order. automation: a stored flow the engine refuses at cold boot is withdrawn with a warn, while the metadata read keeps serving it as status: 'active' — after the v18 upgrade, every refused flow reads as running #23316 imports FlowRegistrationRefusal from @objectstack/spec/api; this PR's RegistrationCheckRefusal is a different, unexported name. automation: a stored flow the engine refuses at cold boot is withdrawn with a warn, while the metadata read keeps serving it as status: 'active' — after the v18 upgrade, every refused flow reads as running #23316 regenerates packages/spec/api-surface/*.json; this PR touches none of those. The joint typecheck is not measured here either; the second PR to queue merges main, and the queue's own Type Check · workspace is the measurement.

② Semver level

The changeset .changeset/23317-stored-preview-gaps.md (minor for @objectstack/spec and @objectstack/service-automation; Clause-②: yes (widening: ...)) matches what the diff publishes. PASS.

  • @objectstack/spec: minor. RIGHT: a published contract member's output widens (IAutomationService.canonicalizeStoredFlow(...).todos? now also carries registration refusals) and the published ConversionTodoNotice.conversionId domain widens by 'registerFlow'. Precedent: record 6152721854 ② and 6154280528 ② put todos? and the conversionId domain widening on minor for this package; .changeset/23158-ai-store-caller-context.md for an additive member on a published contract method.
  • @objectstack/service-automation: minor. RIGHT: AutomationEngine.canonicalizeStoredFlow(...).todos on an exported class's published return now carries more (the method entered as a 17.0.0 Minor Change; precedents .changeset/23178-settings-auth-gate.md, .changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md, and PR fix(spec,service-automation,metadata-protocol): the v18 stored preview lists every row protocol 18 refuses and no conversion rewrites #23315's own line for the same member).
  • @objectstack/metadata-protocol: no line, and none owed. Its code is unchanged (docblocks only); the widened domain of StoredMigrationTodo.conversionId arrives through the spec carrier the changeset names and is declared in the field's docblock. The stricter reading of 6152721854 ① item 5 would also name this package; that record's minor for it rode a type addition (StoredFlowCanonicalization.todos?), which this PR does not repeat. Not a FAIL carrier.
  • @objectstack/runtime: no line, and none owed (① item 8).
  • Clause-②: yes (widening): RIGHT, on PR body line 2 and the changeset's long spelling (readClause2Line takes the arm as the first token inside the parenthesis: value yes, arm widening, as 6154280528 ② measured). The arm is right: nothing narrows, no door accepts or refuses anything it did not before (① items 1 to 4), nothing authorable is removed or renamed, so no breaking marker, no FROM to TO text and no ADR-0087 disposition marker is owed. Triage's and the claim's no was the prediction; the diff is the fact, and the dev flipped it on the diff, which is the direction landing-operations.md wants (a wrong no is the auditable false declaration, a flipped yes on a measured widening is not).
  • skip-changeset does not apply; both named packages publish. Check Changeset and Lint · PM dispatch-gates (check-changeset-no-major, check-adr-0087-registration) are green on this head.

③ Boundary flags

Every deviation and every out_of_scope_findings entry in report 6158463913, answered or escalated.

Implemented-by: claude/issue-23317-stored-preview-gaps
Reviewed-by: session_01BRQ9Mp1yH7R1az6MaUM18H

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 01:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 01:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Queue note · domain:spec seat 2 (#18549) · os-justin · session session_01BRQ9Mp1yH7R1az6MaUM18H · 2026-10-14T01:38Z.

  • What failed: the merge queue removed this PR at 01:36Z with CI_FAILURE. In merge-group run 38496246256 (group base 18cd410dc), Lint · CI, release & engine gates failed its lane verdict on one gate, "Issue citations this change adds resolve on the board" (scripts/check-issue-citations.mjs). It exited 3, PREREQUISITE NOT MET, meaning the board was not read; it does not mean a citation failed to resolve. Lint & Repo Gates is the aggregator failing on that lane.
  • Measured from this seat, on this PR's head ac768f1d5, with the same diff base:
  • Why it is not this PR's: the gate's verdict on this PR's own run was green, the citation set is the same in the queue group, and the failure is GitHub answering 500 to a read. The other queue groups since 01:00Z passed the same lane.
  • What the seat does: it re-queues this PR once. A second failure of this gate is read again, and not re-queued blind.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit dc39326 Oct 14, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23317-stored-preview-gaps branch October 14, 2026 02:07
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/l tests tooling

Projects

None yet

2 participants