Skip to content

Commit dc39326

Browse files
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)
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 #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 #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 #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 (#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. - A1, the page-component judge made to return nothing: 7 failed, 38 passed, predicted red. The four listings, reach, the credential pin and door agreement failed; the five negative controls and two text pins stayed green. - A2, the props-row judge skipped (any API binding listed): 3 failed, 42 passed. `object-kanban`, the custom type and door agreement failed. This is the proof that the row is the judge. - A3, the registration listing removed from `canonicalizeStoredFlow`, re-run on the final lazy form: 13 failed, 21 passed. Every listing pin failed; the on-protocol controls, the parse-throw pin and the log pin stayed green. - A4, the per-site dedupe removed (final form): 2 failed, 32 passed. The dedupe pin and the existing #23269 "exactly one TODO" pin failed. - 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](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18cd410 commit dc39326

13 files changed

Lines changed: 834 additions & 125 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
fix: `os migrate meta --stored` lists the two families of stored rows it still read as on protocol: a page component whose `data` prop binds the retired view-level API source, and every other site `registerFlow` refuses in a stored flow the parse accepts.
7+
8+
Clause-②: yes (widening: `ConversionTodoNotice.conversionId` in `@objectstack/spec` may now name `registerFlow`, the refusing door; `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec` and `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` now also carry each site flow registration refuses; the `onTodo` sink of `applyConversions` now also receives a page component bound to the retired API source; nothing narrows, and no door accepts or refuses anything it did not before)
9+
10+
**A page component bound to the retired view-level API source: refuse and list, by page and component (`view-data-api-provider-retired`).** `object-grid`, `object-map`, `object-gantt` and `object-tree` take a view data source in their `data` prop, so the step-18 retirement of the `provider: 'api'` arm reaches them through the same schema. The listing judges each stored page component with its own props row, the one the component-props gate parses, and lists it only when that row refuses the binding with the retirement's prescription; a component whose `data` is a row array, a custom type and a component's own `dataSource` binding are not listed. The line names the page, the component and its position, shows the request URL and method, and withholds every other request key. Nothing is rewritten: deleting `data` would make the component query its object instead. Left as stored, the page still loads and saves, because the page parse does not judge a component's properties, while `os validate`, `os build` and `os lint` refuse the binding in source. The D3 entry `view-data-api-provider-retired` now names these components in its scope.
11+
12+
**A stored flow refused by `registerFlow` after the parse: refuse and list, by site (`registerFlow`).** The stored pass ran only one of registration's judges, the value-slot one. `canonicalizeStoredFlow` now runs registration's whole validation without registering: every check `registerFlow` runs once the parse has accepted the flow, through the one list `registerFlow` itself throws from. Each refused site is a `TODO registerFlow` line under its row, in the check's own words: a text slot still on the single-brace dialect or with a hole that does not compile, a decision's `mode`, a malformed node or edge predicate, a plugin node's undeclared config key, an `api` flow with no secret. A check added to registration later is listed the same way, with no second change. A site the chain already lists under a step-18 entry is not repeated. What the parse itself refuses, the builtin config contracts' value arm among it, is not a TODO: `canonicalizeStoredFlow` throws it, and the preview reports the row among those that could not be rewritten, in the parse's words.
13+
14+
`registerFlow` refuses exactly what it refused before, with the same message: its checks now return their refusal instead of throwing it, and `registerFlow` throws the first one. The doors that canonicalize a flow without registering it (the metadata save door, a package duplication) read no `todos`, and the stored pass's run of the checks logs nothing, so they behave as before.

‎content/docs/deployment/cli.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1473,7 +1473,8 @@ can produce, because both supply a live one.
14731473
| Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio |
14741474
| A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip |
14751475
| A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying `$and` / `$or` / `$not` | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a `TODO` line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported `skipped`. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand |
1476-
| A stored shape protocol 18 refuses and no conversion rewrites — a `controlled_by_parent` object without exactly one required `master_detail` (two masters, none, a required `lookup` only, an explicit `required: false`), a view bound to the retired view-level API data source, a flow value slot carrying a `{…}` token (a `get_record` / `update_record` / `delete_record` filter operand among them) | Each would need a choice only its owner can make — which master, which page, what an absent value should do — so it is never rewritten. Each site is printed as a `TODO` line under its row, naming the step-18 entry that refuses it, the object, view or flow node, and the owner's choices; a view's request headers are withheld. Until it is rewritten by hand, the parse or the boot refuses the row: an object registers but derives no master, a view registers under `[metadata_spec_invalid]`, and a flow is not registered |
1476+
| A stored shape protocol 18 refuses and no conversion rewrites — a `controlled_by_parent` object without exactly one required `master_detail` (two masters, none, a required `lookup` only, an explicit `required: false`), a view bound to the retired view-level API data source or a page component whose `data` prop binds it (`object-grid`, `object-map`, `object-gantt`, `object-tree`), a flow value slot carrying a `{…}` token (a `get_record` / `update_record` / `delete_record` filter operand among them) | Each would need a choice only its owner can make — which master, which page, what an absent value should do — so it is never rewritten. Each site is printed as a `TODO` line under its row, naming the step-18 entry that refuses it, the object, view, page component or flow node, and the owner's choices; a binding's request headers are withheld. Until it is rewritten by hand, the row is refused: an object registers but derives no master, a view registers under `[metadata_spec_invalid]`, a page still loads and saves while `os validate`, `os build` and `os lint` refuse the binding in source, and a flow is not registered |
1477+
| Any other site `registerFlow` refuses in a stored flow the parse accepts — a text slot's template hole, a decision's `mode`, a malformed predicate, a plugin node's undeclared config key, an `api` flow with no secret | The flow's own definition is wrong, and no conversion rewrites it. Each site is printed as a `TODO registerFlow` line under its row, in the words `registerFlow` refuses it with; a site already listed under a step-18 entry is not repeated. Until it is fixed, the flow is not registered at boot and never runs. A flow the parse itself refuses is reported among the rows that could not be rewritten, in the parse's words |
14771478
14781479
**One thing it lists and never writes: decision nodes that changed meaning at
14791480
protocol 18.** A stored `decision` with no `config.conditions`, no `mode` and two

‎packages/metadata-protocol/src/protocol.stored-migration.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,37 @@ describe('migrateStoredMetadata — the v18 stored shapes refused with no lossle
11841184
expect(text).not.toContain('s3cr3t');
11851185
});
11861186

1187+
it('#23317 — a page whose component binds the retired API source in its data prop is listed by page and component, without its headers', async () => {
1188+
const block = (type: string, data: unknown) => ({ id: 'cases', type, properties: { objectName: 'crm_case', data } });
1189+
const page = (name: string, data: unknown) => ({
1190+
type: 'page',
1191+
name,
1192+
metadata: { name, label: name, type: 'app', regions: [{ name: 'main', components: [block('object-grid', data)] }] },
1193+
});
1194+
const { engine, tables } = makeStubEngine([
1195+
page('partner_page', { provider: API, read: { url: 'https://partner.example.com/cases', headers: { Authorization: 'Bearer s3cr3t' } } }),
1196+
page('case_page', { provider: 'object', object: 'crm_case' }),
1197+
]);
1198+
const before = JSON.stringify(metaRows(tables));
1199+
const protocol = new ObjectStackProtocolImplementation(engine);
1200+
1201+
const report = await protocol.migrateStoredMetadata();
1202+
1203+
expect(report.canonical).toBe(1);
1204+
expect(report.rows.map((r) => [r.type, r.name, r.outcome, r.todos.map((t) => [t.conversionId, t.path])])).toEqual([
1205+
['page', 'partner_page', 'skipped', [['view-data-api-provider-retired', 'pages[0].regions[0].components[0].properties.data']]],
1206+
]);
1207+
const text = formatStoredMigrationReport(report).join('\n');
1208+
expect(text).toContain('page/partner_page [env-wide]');
1209+
expect(text).toContain('The `object-grid` component `cases` of page `partner_page`');
1210+
expect(text).not.toContain('s3cr3t');
1211+
// Listed, never written.
1212+
expect(storedMigrationClean(report)).toBe(true);
1213+
const applied = await protocol.migrateStoredMetadata({ apply: true });
1214+
expect(applied.rewritten).toBe(0);
1215+
expect(JSON.stringify(metaRows(tables))).toBe(before);
1216+
});
1217+
11871218
it('#23269 — a flow whose CRUD filter operand is a {…} token is listed by flow and node; the CEL form is canonical', async () => {
11881219
const { engine, tables } = makeStubEngine([
11891220
crudFlow('fd_refused_filter', '{record.id}'),

‎packages/metadata-protocol/src/stored-migration.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,10 @@ export interface StoredFlowCanonicalization {
6161
/**
6262
* Sites the chain left as stored because no lossless rewrite exists (#23269)
6363
* — above all a value slot carrying a `{…}` template token, which
64-
* `registerFlow` refuses although the parse accepts the flow. Listed under
65-
* the row exactly as a non-flow row's TODOs are ({@link StoredMigrationTodo}).
64+
* `registerFlow` refuses although the parse accepts the flow — and every
65+
* other site `registerFlow` refuses once the parse has accepted it (#23317),
66+
* each under `conversionId: 'registerFlow'`. Listed under the row exactly as
67+
* a non-flow row's TODOs are ({@link StoredMigrationTodo}).
6668
* It restates the contract's own optional member,
6769
* `IAutomationService.canonicalizeStoredFlow(...).todos?` (`@objectstack/spec`):
6870
* a canonicalizer that reports none still conforms, and absent reads as none.
@@ -119,7 +121,11 @@ export interface StoredMigrationNotice {
119121
* this list the row would read as already on protocol.
120122
*/
121123
export interface StoredMigrationTodo {
122-
/** The `MetadataConversion.id` whose surface the site is on. */
124+
/**
125+
* The `MetadataConversion.id` whose surface the site is on — or the D3 entry
126+
* that refuses a stored shape no conversion covers, or `registerFlow` for a
127+
* site flow registration refuses.
128+
*/
123129
conversionId: string;
124130
/** Dotted surface that conversion governs. */
125131
surface: string;

‎packages/runtime/src/dispatcher-error-vocabulary.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,20 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
617617
'pass flattens it onto its report rows, where `os migrate meta --stored` prints it; it ' +
618618
'carries no `code` there. A callback payload, not a thrown error and not a response body.',
619619
},
620+
{
621+
code: 'OS_METADATA_CONVERSION_TODO',
622+
file: 'packages/services/service-automation/src/engine.ts',
623+
shape: 'objlitconst',
624+
door: 'none',
625+
verdict: 'foreign-vocabulary',
626+
why:
627+
'The same conversion-TODO vocabulary as the apply.ts row above, stamped by the automation ' +
628+
'engine for a site its flow registration refuses once the parse has accepted the flow: ' +
629+
"`canonicalizeStoredFlow` returns it in `todos` beside the chain's own TODOs, and the " +
630+
'stored-metadata migration pass flattens it onto its report rows exactly as it does those, ' +
631+
'with no `code`. A returned record, not a thrown error and not a response body: ' +
632+
'`registerFlow` throws its own plain refusal and never this.',
633+
},
620634
{
621635
code: 'ERR_BULK_PER_ROW_HOOK_LIMIT',
622636
file: 'packages/spec/src/data/bulk-write-hook-conformance.ts',

‎packages/services/service-automation/src/builtin/config-unknown-keys.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ describe('a builtin\'s undeclared key is the spec\'s, met at registration (#4277
159159

160160
it('the descriptor walk stands aside for every type the spec judges, and keeps the rest', () => {
161161
const engine = engineWith(recordingLogger().logger);
162-
const walk = (type: string, config: Record<string, unknown>) => () =>
162+
// [#23317] The walk returns its refusal (`registerFlow` throws it), so the
163+
// stored pass can list the same sites without registering.
164+
const walk = (type: string, config: Record<string, unknown>): { message: string } | undefined =>
163165
(engine as any).validateNodeConfigKeys('f', flowWith(type, config));
164166
// One judge per type: the spec already refused these in `FlowSchema.parse`.
165167
for (const [type, config] of [
@@ -169,11 +171,11 @@ describe('a builtin\'s undeclared key is the spec\'s, met at registration (#4277
169171
['try_catch', { try: { nodes: [], edges: [] }, bogusKey: 1, retry: { maxRetry: 2 } }],
170172
] as const) {
171173
expect(builtinNodeConfigKeysJudged(type), type).toBe(true);
172-
expect(walk(type, config), type).not.toThrow();
174+
expect(walk(type, config), type).toBeUndefined();
173175
}
174176
// The walk keeps plugin types.
175177
expect(builtinNodeConfigKeysJudged(STAMP)).toBe(false);
176-
expect(walk(STAMP, { count: 1, bogusKey: 1 })).toThrow(/undeclared config key/);
178+
expect(walk(STAMP, { count: 1, bogusKey: 1 })?.message).toMatch(/undeclared config key/);
177179
});
178180
});
179181

0 commit comments

Comments
 (0)