Repository navigation
feat(spec)!: the build doors judge an approval node config against its declared contract, whole — an undeclared key or a refused value is refused with a location - #21893
Conversation
…t the build doors Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…xtures The publish-gate fixtures' "clean" approval flow carried emptyApproverPolicy, a key the approval contract never declared; the build doors now judge that contract whole, so the fixture was never clean. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8c983543a1a016be212f9d8cdcc22cd5889fff30 && git checkout 8c983543a1a016be212f9d8cdcc22cd5889fff30
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 67c544cccab757d5c27296a4265246c7e3dfffc0 76118d27fe2ed2592da573f73a23c186dc14e063 && git checkout -B drift-repro 67c544cccab757d5c27296a4265246c7e3dfffc0 && git merge --no-ff 76118d27fe2ed2592da573f73a23c186dc14e063
node scripts/docs-audit/affected-docs.mjs --json 67c544cccab757d5c27296a4265246c7e3dfffc0
|
…e carries the approvers its contract requires registerFlow parses FlowSchema first, and the build doors now judge an approval node's config against its declared contract, so the vocabulary-seal fixture's contract-less approval node is refused before the test reaches what it measures. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
…`{ enabled: false }` block, and keeps entered values when switched off (objectui#11660) (objectstack-ai#11661)
Fixes objectstack-ai#11660
Clause-②: no
## What changes
The flow designer's SLA escalation switch on an approval node no longer
writes the refused `escalation: { enabled: false }` block, and switching
it off keeps the values the author entered. This follows triage's
amended direction on the card (comment 6003792818, which replaced the
first grade under objectui#6499 Option C):
1. **No block reads off.** A node without an `escalation` block used to
draw the switch checked and reveal all four escalation fields, because
the inspector applied the declared default (`'true'`) with no block for
it to apply to. It now reads off, and rendering writes nothing. A block
that omits `enabled` still reads on (the objectui#6620 pin is
unchanged), and a stored `enabled: false` still reads off.
2. **Switching off with nothing entered writes no block.** Switching on
writes `{ enabled: true }` alone. Switching back off before anything is
entered removes the block instead of writing the `{ enabled: false }`
stub.
3. **Switching off a block that holds values writes `enabled: false` and
keeps every value** byte-identical. The existing "inactive values
retained" display and its Clear action carry them (objectui#6499 Option
C).
4. **Clearing the last retained value removes the block** instead of
leaving `{ enabled: false }` behind. A block that still holds another
value is kept.
5. **A block with values but no `timeoutHours`** (for example `{
enabled: true, action: 'reassign' }` before hours are typed) keeps its
values when switched off, gains `enabled: false`, and has no hours
filled in. It was refused at `escalation.timeoutHours` before the switch
and stays refused after it, with the field named.
Why the stub was the defect: `ApprovalEscalationSchema.enabled` is
described as "an escalation block carrying timeoutHours is live unless
this is explicitly false — the feature-level switch is whether the
escalation block exists at all", and `timeoutHours` is required whatever
`enabled` says. So both no block and `{ enabled: false, timeoutHours: N
}` are a conforming off, and the runtime skips escalation on an explicit
`false`. The bare `{ enabled: false }` is refused. The inspector wrote
that stub when an author switched off a node with nothing entered,
because the switch drew on over a node that had no block.
## Where the change sits
The designer renders the engine-published `configSchema` when one is
served, and falls back to the offline table otherwise. Both sources emit
the gate at `['config', 'escalation', 'enabled']`, so the rule is keyed
by node type and path in `flow-node-config.ts` (`BLOCK_SWITCHES`). A
descriptor member was not needed. Three internal helpers implement it:
- `switchedBlockOf(node, field)` says whether a field is the switch or a
value inside the switched block.
- `readFieldValue(node, field)` is the stored read, except that the
switch over an absent block reads `false` (rule 1). `controllerAdmits`
and `FlowNodeInspector`'s control value both go through it.
- `isBareSwitchedOffBlock(node, switched)` detects the stub after any
write; `FlowNodeInspector.setField` then removes the block (rules 2 and
4).
Everything else is the ordinary write, which is what keeps entered
values on switch-off (rules 3 and 5). `FlowNodeConfigField` and
`json-schema-to-fields.ts` are unchanged.
`Clause-②: no` holds: no export, prop, type member or i18n key is added
to anything a package entry reaches. The helpers are new exports of the
internal `inspectors/flow-node-config.ts` module, which the
`@object-ui/app-shell` entry does not reach. That was measured on the
built `dist`: walking every relative `import` / `export … from` out of
`dist/index.d.ts` reaches 164 declaration files. The control,
`inspector-registry.d.ts`, is among them. None of
`flow-node-config.d.ts`, `FlowNodeInspector.d.ts` or
`FlowNodeConfigField.d.ts` is, although `flow-node-config.d.ts` is
emitted and declares the new helpers.
## Measured
### Live, this branch (objectstack `main` at `cab63967`)
Setup: the showcase app on a `--fresh` database, the console from this
branch, Playwright Chromium, and a user-authored record-triggered flow
with one approval node. The designer rendered the engine-published
descriptors (the gate is labelled `Escalation`).
- **Rule 3.** A published block `{ enabled: true, timeoutHours: 24,
action: 'reassign', escalateTo: 'sre_lead', notifySubmitter: false }`
sits beside `behavior`, `lockRecord: false` and `maxRevisions: 2`.
Switching it off autosaves `{ enabled: false, timeoutHours: 24, action:
'reassign', escalateTo: 'sre_lead', notifySubmitter: false }` with every
sibling key, and gets 200. The node then reads off with all four values
on screen and four "kept, not in effect" notices, both right away and
when reopened. After publish (200), a new trigger record starts a run
with `start` success, `gate` success, status `paused`.
- **Rule 2.** On a node with no block, switching on autosaves `{
enabled: true }`, which is refused 422 at
`nodes.1.config.escalation.timeoutHours` (see the acceptance notes).
Switching back off leaves the designer at "No changes to save", because
the node again equals the stored one with no `escalation` key. A forced
save (a label edit) sends the node with no `escalation` key and gets
200.
- **Rule 5.** Switch on, then pick Action `Reassign` without typing
hours: autosave sends `{ enabled: true, action: 'reassign' }` and gets
422 at `nodes.1.config.escalation.timeoutHours`. Switching off sends `{
enabled: false, action: 'reassign' }`, again 422 at the same field. No
hours are filled in, and the node reads off with Action retained (one
notice).
### Live, earlier (objectstack `main` at `866683f9`, unchanged paths)
- **Before** (the two source files checked out at `846f982` in the
worktree, then restored and blob-verified): a node with no block drew
the switch **checked** with all four fields revealed. Switching it off
autosaved `escalation: { enabled: false }` and got **422
`INVALID_METADATA`** at `nodes.1.config.escalation.timeoutHours`.
- A stored `{ enabled: false, timeoutHours: 24 }` read off and showed
Timeout Hours with the notice. The page made **zero** metadata writes
while open.
### Unit pins
`FlowNodeInspector.escalationOff-11660.test.tsx` runs every case on both
descriptor sources. The first is the offline table. The second is
`getApprovalNodeConfigJsonSchema()` from the installed
`@objectstack/spec/automation`, which is what objectstack's
`plugin-approvals` hands its approval node descriptor as `configSchema`.
The file pins:
- **Rule 1:** no block reads off and nothing is written on render. A
block that omits `enabled` reads on (lit control). A stored `false`
reads off.
- **Rule 3:** a block holding every value gains `enabled: false` with
every value byte-identical (`JSON.stringify` equal, key order included).
The sibling config keys (`approvers`, `behavior`, `minApprovals`,
`onEmptyApprovers`, `lockRecord`, `maxRevisions` and an Advanced extra)
are byte-identical, and the block parses. Four values show as retained.
A block that omits `enabled` keeps its value and gains `enabled: false`.
- **Rule 2:** switching on writes `{ enabled: true }` alone, with no
`action` or `notifySubmitter` default. Switching back off with nothing
entered leaves no `escalation` key, and an emptied `config` is pruned.
- **Rule 5:** `{ enabled: true, action: 'reassign' }` switched off
becomes `{ enabled: false, action: 'reassign' }`. It gains no
`timeoutHours`, and `ApprovalNodeConfigSchema` refuses it at
`escalation.timeoutHours` both before and after.
- **Rule 4 on a block rule 3 wrote:** `{ enabled: true, timeoutHours: 24
}` switched off, then Clear on the hours, leaves no block.
- **Round trip:** escalation authored on through the inspector (switch,
hours, action select, escalate-to, notify-submitter) parses through
`ApprovalNodeConfigSchema` with every value. Switched off, it carries
`enabled: false` with every value identical, and it parses.
- **Stored off block:** it reads off, is retained and flagged, and
writes nothing. Clearing its last value leaves no stub. Clearing one of
two retained values keeps the other.
Two existing pins moved:
- `FlowNodeInspector.inactiveRetained.test.tsx`, "offers a clear button
that removes the retained key", used to pin the refused `{ enabled:
false }` left after Clear. It now pins no block (rule 4).
- `FlowNodeInspector.declaredDefault.test.tsx`, "every select-kind
declaring field renders its declared default", used to render
`approval.escalation.action` on an empty config. That only drew because
the gate read on over no block. That one case now renders on `{
timeoutHours: 24 }`, with `action` still unset.
### Ablation
Each write branch was ablated separately on the committed state, through
`ablation-replace.mjs` (anchor hit once, blob changed, restore proven
equal to the HEAD blob with `git diff HEAD` empty). The runs cover the
new file plus `FlowNodeInspector.inactiveRetained.test.tsx`, 37 tests.
- **No-values branch** (the bare-stub removal disabled): 9 failed | 28
passed. The failures are rule 2 (two cases), rule 4 on a rule-3 block,
and Clear of the last value on a stored block, each on both sources,
plus the moved `inactiveRetained` pin.
- **Off-with-values branch** (mutated to remove the block whenever the
switch commits `false`, the behaviour this round retires): 10 failed |
27 passed. The failures are the two rule-3 cases, rule 5, rule 4 on a
rule-3 block, and the round trip, each on both sources.
The read half (`readFieldValue`) is unchanged from the first round. Its
ablation there turned 6 pins red.
## Gates
Run from the repo root at `2f7d398`, the branch head. `origin/main` is
still `846f982`, this branch's base, so the pre-PR merge of
`origin/main` was a no-op (`Already up to date`), and there is no merge
commit.
| Gate | Result |
| --- | --- |
| `pnpm exec vitest run packages/app-shell/` | exit 0. `Test Files 1008
passed \| 1 skipped (1009)`, `Tests 10019 passed \| 9 skipped (10028)` |
| `pnpm --filter @object-ui/app-shell type-check` (dependency closure
built first) | exit 0. `tsc -p tsconfig.test.json --listFiles` lists the
new test file (1 hit) |
| `pnpm --filter @object-ui/app-shell lint` | exit 0. `0 errors`, and no
warning on a line this branch adds |
| `pnpm --filter @object-ui/app-shell build` | exit 0. `dist
completeness: 1 package(s) complete` |
| `check:spec-symbols`, `check:new-line-citations` (`0 new
citation(s)`), `check:control-bytes`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:vi-mock-specifiers`,
`check:vi-mock-inherit`, `check:vi-mock-override-shape`,
`check:test-path-roots`, `check:metadata-write-doors`,
`check:unreferenced-sources`, `check:installed-pin-claims`,
`check:designer-field-key-parity`, `check:i18n-designer-parity` | exit 0
each |
| `node scripts/check-changeset-presence.mjs` /
`check-changeset-no-major.mjs` | exit 0 each. One changeset,
`@object-ui/app-shell: patch` |
The full lint farm and every other `check:*` script are left to CI.
## Acceptance notes
- **objectui#6499 Option C is kept for escalation, as ruled** (triage
6003792818). Switching off never deletes an entered value. Removal only
replaces a block that holds nothing but its switch, where nothing
entered is lost.
- **Switching on before typing hours** (unchanged path). Switching on
over no block writes `{ enabled: true }`, and with autosave on that save
is refused 422 at `nodes.1.config.escalation.timeoutHours` until hours
are typed. Timeout Hours shows **no** required marker, because the
objectui#10948 probe and `clientValidation.ts` ask the installed
`@objectstack/spec` 17.6.0, whose `flowNodeConfigRefusals` does not
judge the block yet. Built at objectstack `866683f9`, that judge names
`escalation.timeoutHours` for `{ enabled: true }`, `{ enabled: false }`
and `{}`, and nothing for no block. So the existing probe should light
the marker once objectui installs a spec release containing
objectstack-ai/objectstack#21893. No gate was built here.
- **Stored stubs.** A bare `{ enabled: false }` that the old inspector
already stored reads off and is not rewritten on render or by an edit
elsewhere on the node. After objectstack-ai/objectstack#21893, such a
flow's next save is refused at `escalation.timeoutHours` until the
author touches the escalation block. Switching on and back off (rule 2),
or filling the hours, clears it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
…ses is withdrawn, not left registered and active from the boot pull (objectstack-ai#21897) Fixes objectstack-ai#21848 Clause-②: no (narrowing) ## Current scope: patch round 1 (after objectstack-ai#21893 landed), what was dropped and what remains **Dropped.** objectstack-ai#21893 (merged as `866683f96f`) made the approval node's config contract part of `FlowSchema`: the flow parse now judges an approval node's config against `ApprovalNodeConfigSchema`, whole. `registerFlow` parses `FlowSchema` first, so the admin door and the package-load boot pull already refuse an out-of-range escalation and an undeclared escalation key, located at the config path. Per the seat's decision `5998929933`, there is now one judge in the spec, so this PR drops its own: - `NodeExecutor.configContract` (the optional published member) is removed, and so are `AutomationEngine.validateNodeConfigValues` and its call in `registerFlow`. `engine.ts` is byte-identical to `main` (blob `597e506a7711`). - The `formatIssuePath` export in `builtin/parse-config.ts` is removed (nothing else needed it). The file is byte-identical to `main`. - `plugin-approvals` ends with no change. `approval-node.ts` is byte-identical to `main` (blob `e2027bb0c675`). `approval-node-config-contract.test.ts` is deleted: everything it pinned depended on `configContract`, and the engine-level refusal it also covered is objectstack-ai#21893's pin. - The changeset no longer names `@objectstack/plugin-approvals`. **Remains.** - **The cold-boot withdraw** (`service-automation/src/plugin.ts`). This is a separate, measured defect. The boot pull registers a package's flows before a plugin that contributes a node type has registered its executor, and with it the descriptor `configSchema` the key check reads, so the pull registers such a flow unchecked and arms it. The `kernel:ready` bind then re-registers the flow and refuses it, but used to only warn, so the flow stayed `active` and bound. The bind now withdraws a flow it refuses, and only that flow. Since objectstack-ai#21893, approval configs are refused at the boot pull itself, so the withdraw is measured on a plugin node type that the spec does not declare. - **The door pins** (`packages/qa/dogfood/test/flow-node-config-values-at-registration.dogfood.test.ts`) now pass through the spec's judge. Re-measured at `7333fd869d`: 6 passed. - **Package load.** The sub-hour flow and the undeclared-key flow both answer `404`. The boot line names each flow and the located path (`nodes[1].config.escalation.timeoutHours`, `nodes[1].config.escalation.bogusKey`). The valid escalation registers `active` and runs: it opens one pending approval request. - **Package load, plugin node type.** A flow with an undeclared key on that node type is refused by the `kernel:ready` bind and answers `404`. Its valid sibling answers `200`. - **Admin door.** Both approval refusals answer `400 VALIDATION_FAILED`, with `details.fields` carrying `nodes.1.config.escalation.timeoutHours` and `nodes.1.config.escalation.bogusKey` respectively. `GET` answers `404` for both. The valid escalation registers. - Only the assertions that named this PR's old message text changed: `node 'gate'` became the spec's located path. - **Unit pin** `service-automation/src/flow-cold-boot-refusal-withdraw.test.ts` replaces `node-config-values-at-registration.test.ts`. The withdraw is tested on a real `LiteKernel`, with the plugin node type registered from a later plugin's `start()`. The control shows that the same body registers when no plugin contributes the type. - **Ablation** (`scripts/ablation-replace.mjs`): the withdraw call was replaced by a marker statement (blob `e36e7077781b` to `5f67fd9d27bd`) and present in `dist/` per `ablation-dist-preflight`. Results: unit 1 failed / 1 passed (the withdraw pin red, the control green); dogfood 1 failed / 5 passed (only the plugin-node package-load pin, `200 active`). Restore: blob equals HEAD and `git diff HEAD` is empty. After a rebuild, `--absent` reads the marker absent from all 6 built files, with the tree clean. - **Clause-②** returns to `no (narrowing)`. Nothing published widens now, and the withdraw narrows what stays loaded at boot. The changeset is `minor` with the `!` banner for `@objectstack/service-automation` only. The ADR-0087 marker stays `not-required (no-migration-prescription)`. - **Docs.** `content/docs/automation/flows.mdx` now says the flow parse judges an approval node's config whole, and that a flow refused at boot is not left registered. _Seat's restructure (`domain:services` seat 1, objectstack-ai#6021): the section below is the dev's patch-round-1 notes and states what this PR ships now. Everything under **Round 0 record** describes the first build; where it names `NodeExecutor.configContract`, `validateNodeConfigValues`, the `formatIssuePath` export or the approval executor's declaration, it is superseded (seat decision `5998929933` on objectstack-ai#21848)._ --- ## Round 0 record ## What this changes A flow node's config VALUES are now judged where the flow registers, by the schema the node's executor parses at run time. Before, registration read a node's config against the descriptor's JSON-Schema `configSchema` for its key NAMES only, so an approval node with `escalation.timeoutHours: 0.5` (the contract says `>= 1`) registered through `POST /automation`, loaded `active` from a package, and then failed every trigger-fired run at the approval node with no approval request opened. - **`service-automation/src/engine.ts`.** `NodeExecutor` gains an optional `configContract` (the structural `safeParse` view the builtin executors already parse through, `NodeConfigContract`). `registerFlow` runs a new `validateNodeConfigValues` right after the key-name check: every node whose executor declares a contract has its `config ?? {}` parsed with it, in every graph (`collectFlowGraphs`, so region bodies too), and any finding refuses the flow. The refusal mirrors the key-name one: a plain `Error` (no `code` or `status` of its own), the flow name, then one line per finding with the node id, its type and the config path in the execute-time spelling (`config.escalation.timeoutHours`), followed by the contract's own sentence. No hand-written value check and no JSON-Schema validation: the executor's own contract is the one judge. - **`plugin-approvals/src/approval-node.ts`.** The approval executor declares `configContract: ApprovalNodeConfigSchema`, the very object its `execute` already parses. `ApprovalAutomationSurface` gains the matching optional member. `execute` is unchanged. - **`service-automation/src/plugin.ts` (measured necessity, H5).** The `kernel:ready` cold-boot bind withdraws a flow it refuses. See H5 below for why the package-load door needed this. - **`service-automation/src/builtin/parse-config.ts`.** `formatIssuePath` is exported from the module so both refusals spell paths alike. The package entry (`index.ts`, the only `exports` entry) does not re-export it, so no symbol is added to any package entry. - **`content/docs/automation/flows.mdx`.** The `config` row and the config callout now say registration also judges values for a node type whose executor declares its contract. - **Changeset** `.changeset/21848-node-config-values-at-registration.md`: `minor` for both packages, `!` banner, `Clause-②: no (narrowing)`, handling = correct the value at the located path. ## Mechanism hypotheses, measured - **H1, confirmed.** A red pin on `origin/main` (the dogfood file below at `be7450c78e`, before the fix): `GET /automation/esc_value_packaged_sub_hour` answered `200` with `status: active`; `POST /automation` with the same body answered `200`; the trigger-fired run failed with `Approval node 'gate' has invalid config: escalation.timeoutHours: Too small: expected number to be >=1`. 3 failed, 2 passed (the two controls). - **H2.** Registration learned a plugin node's config keys only from `descriptor.configSchema`, a JSON Schema (the approval node publishes `getApprovalNodeConfigJsonSchema()`, a `z.toJSONSchema` projection). No node type registered the Zod schema `execute` parses. The smallest change that reaches it is an optional executor member, so it touches `service-automation` (the member and the judge) and `plugin-approvals` (the declaration). JSON-Schema validation was not used: the projection drops what JSON Schema cannot say, and the approval contract has such a rule (a `superRefine` refusing `fallbackApprovers` beside a policy that never reads it), which this PR refuses at registration and pins. - **H3.** Node types that declare no contract keep today's behaviour (key names only, where a descriptor publishes `configSchema`). After this PR that is every node type except `approval`: all builtins (`get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `script`, `subflow`, `map`, `loop`, `parallel`, `try_catch` parse their contract inside `execute`; `decision`, `assignment`, `wait`, `connector_action` are schemaless or read sibling blocks), `approval_revise` (schemaless, parses nothing; pinned that it declares no contract), and every third-party node type. No schema was invented for any of them. - **H4, confirmed.** Located refusal, same class as the key-name refusal. Through the admin door the body is `400` with `error.code: VALIDATION_FAILED`, message `Flow 'esc_probe_door' rejected: 1 config value(s) the node's own contract refuses.` then ` - node 'gate' (approval): config.escalation.timeoutHours: Too small: expected number to be >=1`, and `details.fields[0]` = `{ field: '(body)', code: 'invalid_value' }`, exactly the envelope the undeclared-key refusal gets (the runtime domain's `flowDefinitionRefusal` maps any plain error from `registerFlow` this way). - **H5, measured, and it needed a second change.** On `main` the boot pull (`AutomationServicePlugin.start()`) registers package flows BEFORE `ApprovalsServicePlugin.start()` registers the `approval` executor (boot log: the three `Flow registered: esc_value_packaged_*` lines, then `Node executor registered: approval` about 12 ms later), so the pull cannot judge approval nodes. The `kernel:ready` bind re-registers every flow once the executor exists, and refused the flow with a WARN, but the boot pull's registration stayed: measured with an `escalation.bogusKey` flow (the existing key-name refusal) that the WARN refused while `GET /automation/...` kept answering `200 active` and the record-change trigger stayed bound. The same hole would have swallowed this PR's value refusal at package load. The bind now withdraws a flow it refuses (`withdrawFlow`, only the refused name; a failed or empty READ still tears nothing down). After: the sub-hour flow and the bogus-key flow both answer `404` after boot, each with one located WARN (`[Automation] cold-boot flow bind: failed to register flow`), and the rest of the package loads: only the flow is refused, never the package, matching how the boot pull already treats a flow it refuses. ## Tests All at `5fdd32adc9` (the final commit) unless named otherwise: - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/node-config-values-at-registration.test.ts`: 7 passed. Engine pins: 0.5 refused with flow, node and path, and nothing registered; the value refusal and the key-name refusal are the same class (plain `Error`, no `code`, no `status`, same prototype); a valid escalation registers unchanged; the `superRefine` rule refuses at `config.onEmptyApprovers`; a region-body node is judged and named with its region; an executor without a contract keeps key-name-only judgement. Boot pin (real `LiteKernel`, boot pull plus protocol view serving the same packaged flows, the executor registered from a later plugin's `start()`): the refused flow is withdrawn and unbound, the valid one stays bound. - `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/approval-node-config-contract.test.ts`: 2 passed. The declared contract is `ApprovalNodeConfigSchema` itself (identity), `execute` refuses what it refuses, `approval_revise` declares none; on the real engine 0.5 is refused, located, and `timeoutHours: 1` registers. - `packages/qa/dogfood`, new file `test/flow-node-config-values-at-registration.dogfood.test.ts` (`vitest run --project isolated --maxWorkers=2`): 5 passed. Package load: the sub-hour flow answers `404`, and a boot line names the flow, `node 'gate'` and `escalation.timeoutHours`; the valid escalation in the same package registers `active` and runs (a record of its object opens one pending approval request, `pending_approvers` = the position). Admin door: `400`, `VALIDATION_FAILED`, the message names the flow, `node 'gate'` and `escalation.timeoutHours`, and `GET` answers `404`; a valid escalation registers. - Full suites at `2ef0c612b2` (the code is byte-identical at `5fdd32adc9`: `git diff 2ef0c61..HEAD -- packages/services packages/plugins packages/qa` is empty): `@objectstack/service-automation` 173 files, 2117 tests passed; `@objectstack/plugin-approvals` 61 files, 897 tests passed. - `pnpm --filter @objectstack/service-automation --filter @objectstack/plugin-approvals run typecheck`: both `Done` (`tsc --noEmit` plus `check:test-typecheck`, which compiles the test layer). `pnpm --filter @objectstack/dogfood run typecheck`: exit 0, and `tsc --listFilesOnly` shows the new dogfood file in the program. - Census of every approval node in the example stacks and dogfood fixtures (showcase, crm, multi-package, the four approval fixtures), parsed with `ApprovalNodeConfigSchema`: 36 flows, 19 approval nodes, 0 refused, so no shipped flow stops registering. ## Ablations (the refusal pins can fail) Both through `scripts/ablation-replace.mjs` (WRAP mode, its own restore trap) on the committed fix, `service-automation` rebuilt inside the leg and `scripts/ablation-dist-preflight.mjs` proving the marker in `dist/` before any suite ran. - **A: the key-name-only check put back** (the `validateNodeConfigValues` call replaced by a marker statement; anchor 1 to 0, blob `b5e94fb23b67` to `bd7099e9875b`). Preflight: marker present in 2 built files. The build's DTS step exited 1 (TS6133: the ablation leaves the private judge unused); the ESM and CJS bundles the suites load were emitted, which the preflight reading confirms. Results: service-automation 5 failed / 2 passed (the 2 are the valid-escalation and no-contract controls); plugin-approvals 1 failed / 1 passed; dogfood 3 failed / 2 passed (package-load 404, the boot line, the admin-door 400; the two controls pass). - **B: the cold-boot withdraw removed** (anchor 1 to 0, blob `fb06fd8ad148` to `0d523279cf1c`). Preflight: marker present in `dist/`. Results: service-automation 1 failed / 6 passed (only the boot pin); plugin-approvals 2 passed; dogfood 1 failed / 4 passed (only package-load `404`; the boot line still locates the value, so the two halves are independent). - **Restore, both legs:** blob after restore equals the HEAD blob and `git diff HEAD` is empty (the tool's own reading); then `service-automation` rebuilt (exit 0) and `ablation-dist-preflight --absent` for both markers: absent from all 6 built files, working tree clean. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `5fdd32adc9` derived 95 commands; every one was run with its exit code recorded, and `--ran` answered `95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN` (exit 0). Three needed a rerun, each for a measured cause outside this diff: `check-comment-mask-corpus` read a scratch probe file mid-deletion (rerun 0); `spec check:skill-examples` refused on a `packages/spec` dist built before the `main` merge (spec rebuilt, all cache hits; rerun 0); `check:dual-build-cjs-loads` refused on 8 packages with no `dist/` (built, all cache hits; rerun 0). The 17 commands the dispatch named beyond that derivation (the spec export, ledger and doc families) were also run: all exit 0. ESLint on the 7 touched TypeScript files (`--no-inline-config --format json`): 7 files, 0 errors, 0 warnings; the config's own globs cover all 7, and `eslint.config.mjs` enables no type-aware linting, so this diff cannot move any untouched file's verdict. The repo-wide lint is CI's. ## ADR-0087 disposition `not-required (no-migration-prescription)`, in the changeset marker, with no D3 ledger entry and no `packages/spec` edit: no authorable key, spelling, export or stored shape moves, `ApprovalNodeConfigSchema` and `FlowSchema` parse exactly what they parsed, `execute` accepts what it accepted, and which value an author meant is not something a ledger entry can rewrite. This is the disposition this repo's other runtime refusals of what already failed later declared. `check-adr-0087-registration --base origin/main`: exit 0. ## Overlap with the build-door card objectstack-ai#21850 (claimed in `domain:spec`, in flight on `claude/issue-21850-approval-node-config-build-refusal`) is not addressed here: this PR does not touch `objectstack validate` / `compile`, `flow-node-config-refusals.ts` or any spec file. Its WIP judges the approval contract whole inside `FlowSchema`'s superRefine, which `registerFlow` parses first; once it lands, an approval value refusal will surface from that parse (a Zod error mapped through `fieldsFromZodIssues`) before this judge is reached, and this judge keeps covering every node type whose executor declares its contract without the spec knowing it. The dogfood pins assert the status, the code and the located path (`escalation.timeoutHours`, `node 'gate'`), not a full sentence, and build the fixture with `strict: false` so the build door does not stop the invalid body before the runtime doors it pins. The measured boot hole above also bears on that card's repro step 3: on `main` the unknown-key flow was not dropped at boot, it stayed `active` and bound; after this PR it is dropped. ## Acceptance notes - **The builtin half of the same class** (reported to the seat, not filed here): a builtin node with a present value its contract refuses still registers. Measured at `5fdd32adc9`: `POST /automation` with a `create_record` node carrying `outputVariable: 42` answered `200`, and `POST /automation/:name/trigger` answered `400 FLOW_FAILED` with `config does not satisfy the create_record contract — config.outputVariable: Invalid input: expected string, received number`. Wiring the builtins is not mechanical (`http` parses after interpolation, `loop` parses conditionally, the region containers' contracts contain their regions) and is not in this card's file surface. - **Executors registered after the cold-boot bind**: a third-party plugin that registers its executor from its own `kernel:ready` handler, after this plugin's bind, would leave a boot-pulled flow judged only on later registrations. No in-repo node type does this (the builtins register at `init`, `approval` at `start`). Not measured further. - **The metadata save door** (`PUT /meta/flow/:name`) re-arms through the mutation sync, whose refusal handling this PR does not change. Not measured. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…decision in words instead of a tracker number (stage 21) (objectstack-ai#21907) Part of objectstack-ai#20749 Clause-②: no Stage 21 of this card: the next area of class (e), the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the second name-ordered `ui/` group: the test files directly under `packages/spec/src/ui/` from `component-record-blocks.test.ts` to `dashboard.test.ts`. Those files carried 103 messages and 109 tracker-shaped ids, citing 58 records. 105 of those ids now either state what their record decided, in words (form D), or are dropped where the title already says it. Four stay: they are CSS colour literals in two widget fixtures, not citations (below). Text only: no assertion, identifier, test count or code comment changes, and no file is renamed. ## Census at the base (`1e18a0735c`) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5 `dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages 10 to 20 used. A literal counts as a test title when its folded message is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` / `.only` chains included. Everything else is an "other" string. The base is `1e18a0735c`, stage 20's landing and the claim's base. Both instruments read **764 messages / 807 ids in 159 files**, the seat's reading and stage 20's head reading. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `ui/` (this PR: 7 of the 53 files) | 53 | 300 / 318 | 284 / 302 | 16 / 16 | | `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 | | `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 | | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | `ai/` | 1 | 2 / 2 | 0 | 2 / 2 | | `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 | | **total** | **159** | **764 / 807** | **710 / 752** | **54 / 55** | The group reads **103 messages / 109 ids in 7 files**, the seat's figures file for file: | file (under `ui/`) | messages / ids | titles | other | |:--|--:|--:|--:| | `component-record-blocks.test.ts` | 6 / 6 | 6 / 6 | 0 | | `component-reference-rail.test.ts` | 2 / 2 | 2 / 2 | 0 | | `component-type-vocabulary.test.ts` | 3 / 3 | 3 / 3 | 0 | | `component.test.ts` | 65 / 70 | 65 / 70 | 0 | | `dashboard-chart-structure-refusal.test.ts` | 2 / 2 | 0 | 2 / 2 | | `dashboard-compareto.test.ts` | 5 / 5 | 5 / 5 | 0 | | `dashboard.test.ts` | 20 / 21 | 17 / 18 | 3 / 3 | | **7 files** | **103 / 109** | **98 / 104** | **5 / 5** | `component-report-items-action-members-typed.pin.test.ts` sits in the same name range and carries no id. The five "other" strings are the four colour literals and `dashboard.test.ts:205`. - **Controls.** Lit: `ui/view.test.ts`, outside the group, reads 43 ids at the base and at the head. Dark: `component.test.ts` reads 0 at the head while 200 of its comment lines still carry a number. Planted in scratch copies of head files: an id put into a `component-record-blocks.test.ts` title reads 1 / 1, and an id put into a `dashboard-compareto.test.ts` comment reads 0. - **A wider pattern** (any `#` plus digits) reads the same as the gate pattern in 6 of the 7 files at the base. `component.test.ts` reads one more: `decision batch objectstack-ai#77`, a two-digit batch number the gate pattern does not count. It leaves with the id beside it, as stages 9, 13 and 15 did with theirs. - **At the head:** 665 messages / 702 ids in 154 files. The 7 files read 4 / 4 (the four colour literals), `ui/` reads 201 / 213, and no other file moved. ## How the area was chosen `ui/` has no subdirectory test file with an id, so it is taken in name-ordered file groups near the ~100-id bound. Stage 20's re-cut named this group at 109 ids, and this census reads 109, so no re-cut was needed. **Named for the next stages** (cut from the head census, 665 / 702): - `ui/` 213 ids. One is stage 20's kept `component-props-unknown-members.pin.test.ts:322`, four are this group's colour literals, and 208 sit in the 45 files after `dashboard.test.ts`. The next group nearest 100 runs from `dataset-filter-nested-relation-list.test.ts` to `view-inline-object-binding.test.ts`: 29 files, 96 messages / 102 ids, 7 of them "other". Cutting two files earlier gives 98. The last `ui/` group is then the 16 files from `view-item-config-type.test.ts` to `widget.test.ts`: 100 messages / 106 ids, `view.test.ts` alone 43. - `api/` 201, two stages. `system/` 165, two. The files directly in `src/`, 120, one. - The three docblock needles plus the kept `:322`, one stage, with an at-tier review. ## The five "other" strings: four kept, one rewritten - **`'objectstack-ai#111'` / `'objectstack-ai#222'` at `dashboard-chart-structure-refusal.test.ts:94` and `dashboard.test.ts:124` are kept.** They are three-digit CSS hex colours, not placeholders for a record: `colors: ['objectstack-ai#111', 'objectstack-ai#222']` in a widget's `chartConfig`, and `palette: ['objectstack-ai#111', 'objectstack-ai#222']` inside the free-form `options` bag. They cite nothing. But they are not input and expected value at once, the case stage 12 dropped. Each is input that the schema under test reads (`ChartConfigSchema.colors` is a string-array or string-map union), and the assertions read other things: the first parse must succeed, the second asserts `options.stacked`. So by the claim's rule they stay, and are reported. They match the gate pattern only because a short hex colour can be all digits. They are the only four such literals in the whole census. - **`dashboard.test.ts:205`** is the label argument of the file's `orderPin` helper, which passes it to `it()`. So it is a test title one call down, and no assertion reads it. It is rewritten and declared to the text-only tool. No string in the group is a needle (an id that is the expected value of an assertion over a docblock or another file's text). The three known needles are in `ai/` and `contracts/`. ## What each id became - **21 literals (21 ids)** now state a decision in words. - **15 literals (15 ids)** get their subject back in words, where the number stood for a thing, such as "the objectstack-ai#11661 keys". - **63 literals (69 ids)** drop a number the title already explains. Every cited record was read with its comments through REST: 54 answer 200 and 4 answer 404. Four citations are cross-repo: `ui#6206` (also spelled `ui#6206-B`), `ui#6207` and `objectui#8221` were read from objectui and answer 200. `framework#2501` was read under the repository's current name, as stage 18 read `framework#2536`. objectstack-ai#5042 is a pull request, `objectstack-ai#4001` batch 14. The four 404s were read from what landed, through the commits the stage-5 comment sweep (objectstack-ai#20576) re-anchored them to: - **objectstack-ai#6276:** `78f0be872`, which declares the record picker's flat `sort` / `limit` (maintainer ruling 2026-08-08, direction A). - **objectstack-ai#9972:** `60e0f900a`, which records the live read point of `page:tabs` `items[].icon` and its accept pin. - **objectstack-ai#11507:** `88b9d749a`, which declares `sys_activity.type` an open, author-extensible vocabulary (maintainer ruling 2026-08-24, direction 4). - **objectstack-ai#11658:** `1a6a19c31`, which opens `RecordActivityProps.types` to author-contributed kinds, executing that ruling. Each of those titles already carried its decision, so the number is dropped. `component.test.ts:3037` also gets its subject back: "the ruling" becomes "the open-vocabulary ruling". **Stated in words:** | record | literal (under `ui/`) | now reads | the decision | |:--|:--|:--|:--| | objectstack-ai#8744 | `component-record-blocks.test.ts:355` | "the `record:discussion` / `record:chatter` pair — one shared row" | `record:discussion` is wired to the chatter row on purpose: one renderer, one accept face. | | objectstack-ai#7702 | `component.test.ts:63` | "accepts a header without title — the synthesized shape, headed from the record" | Ruling A/B: `title` becomes optional, and an omitted title means the renderer derives the heading from the record. | | objectstack-ai#6776 | `component.test.ts:89`, `:262` | "PageHeaderProps recordChrome / showStar / showCopyId — declared because the header renderer reads them"; "PageAccordionProps variant — declared because the accordion renderer reads it" | Route A: declare the five author keys objectui's renderer reads. | | objectstack-ai#6776 | `component.test.ts:235` | "PageTabsProps tabStyle — renamed from `type`, which collides with the component `type`" | Route A: rename `type` to `tabStyle`, because in a flat node `type` is the component's own dispatch key. | | objectstack-ai#6946 | `component.test.ts:122` | "PageHeaderProps icon is retired — no renderer reads it" | Maintainer ruling: retire three keys with zero renderer read points, this one among them. | | objectstack-ai#5775 | `component.test.ts:406`, `:2932` | "… items[].value / items[].count — declared because the tabs renderer reads them"; "RecordPathProps stages[].terminal — declared because the path renderer reads it" | Ruling A: retire the dead keys and declare the keys the renderers honour. | | objectstack-ai#5775 | `component.test.ts:649` | "PageContainerProps — page:section / page:footer / page:sidebar compose through `children`" | Same ruling: the three containers declare `children` as their one composition key, and `body` is not declared. | | objectstack-ai#11289 | `component.test.ts:768` | "preserves the section presentation keys the renderer honours, verbatim" | Direction 1: declare `hideEmpty` / `collapsible` / `showBorder`; the renderer is unchanged. | | objectstack-ai#11661 | `component.test.ts:889` | "still refuses `title`, deliberately withheld as a second spelling of `label`" | Three more renderer-honoured keys are declared; `title` stays out because `label` already names the heading slot. | | objectstack-ai#9220, objectstack-ai#9249 | `component.test.ts:2078`, `:2142`, `:2899`, `:2908` | "Interactive Elements — element:filter (retired, no renderer)", the same for `element:form`, and "… through the kept map row (retired, no renderer)" twice | Both elements are retired at element grain: no renderer or reader in any repository. | | objectstack-ai#4001 | `component.test.ts:3325` | "batch A, unknown keys refused — the prescriptions, each backed by a measured producer" | Every authorable surface refuses unknown keys; spelled as stage 15 spelled `objectstack-ai#4001 batch D`. | | objectstack-ai#7751 | `component.test.ts:3419` | "object-* block props schemas — declared, so the props gate has a schema to dispatch" | Ruling A: the `object-*` block family enters `ComponentPropsMap`. | | `ui#6207` | `component.test.ts:3468` | "object-grid `data` takes the ViewDataSchema provider object — the two spec authorities converged" | Option A: `object-grid.data` converges on `ViewDataSchema`, and the bare array is refused. | | objectstack-ai#19228 | `component.test.ts:4499` | "row caps on the object-bound blocks — a bound view fills `limit` only when the authored one is not a usable cap" | Ruling D: one row bound per view; the component face keeps an undefaulted `limit`, which a bound view's page size fills whenever the authored one is not a usable cap (the gate's `!isUsableRowLimit(authored)`). | | objectstack-ai#4614 | `dashboard.test.ts:419` | "date-range preset vocabulary — one source, in the spec" | Ruling A: the preset names move into the spec as their one source, and a date filter's default is checked against them. | | objectstack-ai#16458 | `dashboard.test.ts:850` | "control — `columns` still declares no default … (the renderer infers it from widget spans)" | Item 4 was not landed: a `.default(12)` would retire the renderer's span inference and switch every auto-flow dashboard to the positioned grid. | **Subject back in words** (15 literals): "the objectstack-ai#5068 gate" becomes "the props gate" (`component-reference-rail.test.ts:34`); "the three objectstack-ai#18305 blocks" / "object blocks" become "object-map / object-gantt / object-tree" (`component-type-vocabulary.test.ts:88`, `component.test.ts:4295`); the four "objectstack-ai#11661 keys" titles name `defaultCollapsed` / `icon` / `description` (`component.test.ts:832`, `:848`, `:860`, `:875`), because id-free sibling titles already say "the section presentation keys"; "objectstack-ai#18639 scope fences" becomes "scope fences of the `columns` widening"; "the twin of the objectstack-ai#14406 census pin" becomes "the twin of the census pin that no door refuses the rule array"; "the objectstack-ai#7750 specimen shape" becomes "the my-work specimen shape"; the four `objectstack-ai#5011 —` prefixes in `dashboard-compareto.test.ts` become `compareTo —` where the title needs a subject, and go where it already has one (`:61`); "the words objectstack-ai#5042 measured" becomes "every word authors were measured spelling … reaches …". **Dropped where already stated** (63 literals, 69 ids). A number goes only where the title already says its decision. Examples: "ComponentPropsMap[\"record:alert\"] (objectstack-ai#8744)"; "user:profile is not author-placeable (objectstack-ai#14159, ruling B)"; "ai:chat_window is retired, refused by name (objectstack-ai#21504)"; the six "(objectstack-ai#6276)" picker titles, each already naming what the declaration does; "the four `object-*` `sort` doors — one sort orthography, the array (objectui#8221, decision batch objectstack-ai#77, option B; objectstack-ai#18305)"; the `[objectstack-ai#4876]`, `[objectstack-ai#5010]`, `[objectstack-ai#17779]`, `[objectstack-ai#20958]` and `[objectstack-ai#21293]` prefixes on `dashboard.test.ts`, each in front of the rule it names; "drill branch (objectstack-ai#5022): …", whose sibling labels already read "… branch: …". `批 17` stays as a batch label in stage 18's form, and `ADR-0021` / `ADR-0049` style citations are untouched. **No file is renamed.** None of the 7 file names carries a number. ## Readers - **Test-name filters:** none. No tracked script, workflow or package config passes `-t` / `--testNamePattern` (the hits are `mapfile -t`, `docker build -t`, `type -t`, `lsof -t`, and a preflight's option vocabulary). - **Snapshots:** none. No `__snapshots__` directory is tracked under `packages/spec`, and none of the 7 files calls a snapshot matcher. - **Projects:** all 7 files run in the `local` project; `packages/spec/vitest.repo-tests.json` lists none of them. - **By substring:** every old literal, its id-bearing fragment and a window around each id (301 needles) was searched with `git grep` at the base, across the tracked tree outside its own file. No gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads one. The 6 hits: - a code comment in `lint/src/validate-component-props.test.ts:540` ("the objectstack-ai#7750 specimen shape"); - a QA checklist `source` entry, `docs/qa/platform-checklist/areas/dashboards.json:406`, which anchors on `dashboard-compareto.test.ts#dashboard` and describes it in its own words ("objectstack-ai#5011 — compareTo converged on …"). The checklist gate looks up the `dashboard` symbol, which this PR leaves in the file, and reads none of the titles. `pnpm check:platform-checklist` exits 0 at the head; - a sibling title in `service-analytics` (`dataset-compare-dimension-resolution.test.ts:74`, "objectstack-ai#5011 — …"); - three hits on one same-id title in this card's later `ui/` stage, `ui/view.test.ts:4236`, the visibility twin of the message-order describe, citing objectstack-ai#6416 / objectstack-ai#6619. ## Text-only proof Stage 10's scratch tool (`textonly10.cjs`, md5 `d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file on three legs: 1. **Skeleton:** the full AST, with string pieces masked. It must be identical. 2. **Comments:** every comment, byte-equal. 3. **Strings:** each changed string leaf must sit in a test-call title position or on a declared line, must carry a tracker id before, and must carry no `#` plus digits after. This stage declares one line, `dashboard.test.ts:205`. - **Result:** 7 of 7 files SAME on all three legs, with the per-file counts predicted in writing before the run. - **Totals:** 99 changed string leaves in 99 literals: 98 titles and 1 declared. The diff's `+` and `-` lines are exactly the 99 planned lines as multisets, and every file keeps its line count. `dashboard-chart-structure-refusal.test.ts` is untouched. - **Controls (13 of 13 as predicted, on scratch copies, each anchor hit once):** identifier rename DIFF; numeric literal DIFF; comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten title given a new id VIOLATION; a title that was id-free at base edited VIOLATION; one title reverted to base SAME; an `it.each` row given an id VIOLATION; an undeclared `orderPin` label changed VIOLATION; a title re-split into a `+` chain DIFF; the declared label reverted to base SAME; the declared label given a new id VIOLATION; a kept colour literal edited VIOLATION. The first run predicted VIOLATION for the declared-label revert: putting `(objectstack-ai#5022)` back where it was reproduces the base text exactly, so SAME is the right answer, and a control that gives the label a new id was added. That run read 11 of 12. - **Templates and tables:** no `.each` title, `%s` / `$name` placeholder or table row changes. **Test counts:** the 7 files were run at the base, in a separate base worktree, and at the head, with `--project local --project repo`. Both sides read 677 tests in 7 files, all passed, with the same count and status sequence per file in 7 of 7. 469 full test names change, and each changed name equals the base name with the planned replacements applied (0 mismatches). One full name repeats 5 times on both sides: an `it.each` row in `dashboard.test.ts` whose printed name is cut at the same point for 5 rows. Only its describe prefix changed. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 6 touched files are in it, and no `*.test.ts` at all. The controls `src/ui/component.zod.ts` and `dist/index.mjs` are in it. - In the built `dist/`, a new phrase and an old literal each read in 0 files. The control `Unrecognized key` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `ec96327761`) - `pnpm turbo run build` over all packages: 71 / 71, through the shared verify lock (`VERDICT command-exit 0`). - `@objectstack/spec`: - `vitest run --project local`: 618 files, 18450 passed, 1 todo. - `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246 errors / 135 pinned signatures held). Its program holds all 7 group files, counted with `tsc --listFilesOnly -p tsconfig.test.json`. - `check:generated`: all 15 generated artifacts up to date, against the `dist/` the build above wrote. - **Gates:** `dispatch-gates --commands` derived 79 families, the same set as stage 20, and all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded. The five roster families whose rosters sit under a touched directory were also run, and each exits 0: `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. So does `check:platform-checklist`, for the checklist entry above. - **ESLint, a proven narrowing:** `--no-inline-config` over the 7 files reads 0 errors and 0 warnings. The population comes from ESLint's own config: 7 configured, 0 ignored. No file sets `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 198 changed lines (+99 / -99). - A control-byte scan over the 6 changed files finds none. - **Review round 1, at `69ea423302`:** two titles reworded, one line each: the row-cap describe (`component.test.ts:4499`) now states the gate's guard, and the offset-alias title (`dashboard-compareto.test.ts:160`) reads straight. Text-only proof against the base: 7 of 7 SAME, 99 changed leaves (98 title, 1 declared). The 7 files at base and head: 677 / 677 passed, count and status sequence identical in 7 of 7, 469 changed full names, 0 mismatches against the plan. ESLint over the 7 files: 0 errors, 0 warnings. The group census still reads 4 / 4. `typecheck` exit 0, and `check:nul-bytes` OK. The derived gate set is the same 79 families; they were not re-run for a two-literal change. ## `main` since the base Re-fetched just before this PR opened, `origin/main` was four commits past the base (`cab6396715`: objectstack-ai#21875, objectstack-ai#21896, objectstack-ai#21893, objectstack-ai#21900). None touches any of the 7 files. objectstack-ai#21893 touches 8 `packages/spec` files under `automation/` and `migrations/`, and the census over `packages/spec/src` at `866683f96f` (after objectstack-ai#21893) still reads 764 / 807 with no file moved, so the merged tree reads this PR's 665 / 702. objectstack-ai#21900 touches two more `packages/spec` files, both non-test migration entries, which this census does not count. So `main` was not merged. `git merge-tree` onto `cab6396715` is clean. ## Acceptance notes - **The four colour literals** stay, as above. They are the only digit-only hex colours in the census, so the card's end state will read them unless a later stage changes the fixture values, which would be a fixture change rather than a text change. - **Same-id test titles in this card's later stages** go with those stages: 21 lines in `packages/spec/src`, for example `system/i18n-resolver.test.ts:1982` ("(objectstack-ai#20940)"), `ui/page.test.ts:696` ("(ui#6206-B, objectstack-ai#15442)"), `ui/view-strictness-batch18.test.ts:91` ("objectstack-ai#4001 批 18 — …"), `ui/view.test.ts:4236` ("(objectstack-ai#6416 / objectstack-ai#6619)") and `ui/view.test.ts:4793` ("(objectstack-ai#19228)"). - **Same-id test titles in other packages** are their lanes' test-string shares. A search of `describe` / `it` / `test` lines outside `packages/spec/src` finds 40 lines citing ids this PR handled, in 10 packages: `lint` 25 (8 files), `service-analytics` 3 (1), `cli` 2 (1), `platform-objects` 2 (1), `plugin-audit` 2 (2), `plugin-security` 2 (2), and one each in `plugin-sharing`, `rest`, `service-automation` and `spec/scripts`. Examples: `lint/src/validate-component-props.test.ts:783` ("… are dispatched (objectstack-ai#8744)"), `service-analytics/src/__tests__/dataset-compare-dimension-resolution.test.ts:74` ("objectstack-ai#5011 — …"), `rest/src/meta-types-schema-titles.test.ts:131` ("objectstack-ai#16458 — …"). The two `[objectstack-ai#6206]` hits in `plugin-security` and `plugin-sharing` cite objectstack#6206, a different record from `ui#6206`. - **Code comments still carry ids** in these files and their sources, for example the `objectstack-ai#9198 tombstone` comment in `component.test.ts`, the `objectstack-ai#19228` header above `component.test.ts:4499`, and the `objectstack-ai#6416` / `objectstack-ai#5955` docblock above `dashboard.test.ts:163`. Comments are not this card's share, and none is touched here. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21850
Clause-②: yes (narrowing)
The build doors now judge an
approvalnode'sconfigagainst the contract the spec declares for it,ApprovalNodeConfigSchema, whole.escalation.bogusKeyandescalation.timeoutHours: 0.5are each refused with a location atFlowSchema.parse,objectstack validateandobjectstack compile. Before this, both exited 0. The existing alias text ("did you meantimeout→timeoutHours") stays in the refusal. No plugin is loaded at build time, and no node type joins the map unless the spec declares its contract.Draft. Patch round 1 adds the one
domain:servicesfixture line that the claim revision now covers (see "The onedomain:servicesfixture" below).Two route changes, both measured before the edit
The dispatch route was to put
approvalintogetBuiltinNodeConfigContractsbeside the 13 builtins. I tried exactly that on the pristine base (5e0b489bca), rebuilt spec (the dist preflight found the marker in 20 built files) and measured two problems:service-automation'snode-config-contract-ledger.test.tsreads everyparseNodeConfigcall in its ownbuiltin/sources. Withapprovalin the map, 2 of its 5 tests went red: "the map names exactly the node types an executor parses a config contract for" and "every builtin node type is classified". That ledger isdomain:servicescode, so this PR cannot change it.flowNodeConfigRefusalskeeps an issue only when the key it names is absent. The shippedflow-node-config-required-keys-refusedentry says the same thing. Withapprovalin the map,FlowSchemastill accepted both card pins (success: true). Only an approval node with noapproverswas refused.What this PR does instead:
getDeclaredPluginNodeConfigContracts()is private to the module, holds[APPROVAL_NODE_TYPE, ApprovalNodeConfigSchema], and is built on first use, never at module load. The same judge reads it.approval.zod.tsimports nothing fromautomation/(zod, the membership-role leaf,lazySchema,strictObject), so no import cycle is added.plugin-approvals,approval-node.ts) runssafeParseonnode.configbefore it does anything else and fails the node on any issue, so every issue the contract raises is refused:node-config-refused-by-contractwithparams: { nodeType, key }. It is anchored at the key:escalation.bogusKey, or one refusal per key for top-level keys.node-config-key-missingornode-config-key-required-by-rule.httpnode with an undeclared key still parses.getBuiltinNodeConfigContractskeeps its export, shape and contents (the 13 builtins). A caller who looks upapprovalgetsundefined. The approval contract is reachable through the exported judge,flowNodeConfigRefusals('approval', config).Census, before any edit (at
5e0b489bca)I wrote a census script that walks the TypeScript AST and checks every literal approval node
configwithApprovalNodeConfigSchema.safeParse. Non-literal configs were read by hand. Lit controls: the same search pattern findsdecisionnodes inapp-crmandapp-todo, which author flows but no approval nodes, and it finds the known showcase hitdynamic-approval.flow.ts.examples/**: 15 approval nodes, all in the showcase, all accepted.content/docs/**: 6 snippets, accepted (3 by the script, 3 by reading).skills/**: 5 snippets, accepted.packages/qa/dogfoodfixtures: 6 nodes, accepted.0abd4f9f87, read-only: the designer's approval seeddefaultNodeExtras('approval')({ approvers: [{ type: 'manager' }], behavior, lockRecord }) is accepted. objectui'sflow-canvas-seeds.spec-parse.test.tsxparses seeds withFlowNodeSchema, which never calls this judge.flow-required-keys.tsasks the judge only whether some refusal names the probed path, and nothing here changes that answer for a missing key.No real writer is refused. In test code, 5 fixtures needed changes. They are listed under "Fixtures" and under "The one
domain:servicesfixture".Reproduction, before and after (a scratch copy of the showcase)
I added an
escalationblock to theco_signapproval node indynamic-approval.flow.ts. The script proved each edit landed on disk and restored the file byte for byte afterwards.5e0b489bca(main){ timeoutHours: 2, action: 'notify' }{ timeoutHours: 2, action: 'notify', bogusKey: 1 }✓ Validation passed) · compile 0 (✓ Build complete),bogusKeywritten todist/objectstack.jsoncustomatnodes.2.config.escalation.bogusKey{ timeoutHours: 0.5, action: 'notify' }0.5written to the artifactcustomatnodes.2.config.escalation.timeoutHoursBranch wording at the validate door: "This
approvalnode's config is refused atescalation.bogusKeyby the approval contract: Unrecognized key(s) on this approval escalation:bogusKey. …". For thetimeoutalias, the contract's "Did you meantimeout→timeoutHours?" is carried through, next to theescalation.timeoutHourskey-missing refusal.Doors pinned (
flow-approval-node-config-contract.test.ts)Each door has a valid approval node as its control:
FlowSchemarefuses both pins, and the alias, top-level undeclared keys, a missingapproversand a rule finding (onEmptyApprovers: 'fail'together withfallbackApprovers).defineStackrefuses withSTACK_SCHEMA_INVALID/ 422 atflows.1.nodes.1.config.escalation.bogusKey.ObjectStackDefinitionSchemarefuses. This is the stack parse that validate and compile run.flowtype schema used by the metadata save door refuses.validateStackExpressionsdoor shows up in@objectstack/lint's run asflow 'leave_approval' · node 'approve' (approval) config.emptyApproverPolicy.Ablation. I committed the fix first, then deleted the
[APPROVAL_NODE_TYPE, …]entry withscripts/ablation-replace.mjs: anchor count 1 → 0, blobf915eb58bcd0→90e86f48dcc4. The subject resolves throughsrcby relative import, so no build was involved.flow-slot-refusal-codes.test.ts(the new code's pin, and "every code is reached").Tests 14 failed | 26 passed (40), matching the prediction.git diff HEADempty.The ADR-0087 kit
entries/semantic/18.flow-approval-node-config-contract-refused.ts, with itsregistry.tsregion regenerated bygen:migration-registry.origin/mainate085a8c3bebefore opening this PR, and again at67c544cccain patch round 1: the highest order there is 83, and this id is absent. If security(data): a predicate-scoped update or delete is refused when its predicate matches only rows the caller cannot read, and succeeds with zero rows when it matches nothing: an existence signal the read door withholds #21829 or automation: an approval node's escalation values are checked only at execution — timeoutHours 0.5 registers and activates, then every run fails and the record is created with no approval gate #21848 also takes 84, the two fragments render in id order, as the registry header allows.minorchangeset for@objectstack/specwith theregisteredmarker and theClause-②line, at the level the precedents set (fix(spec)!: refuse a flow node config its executor cannot run — a required key left out, or a decision branch list it cannot read — at all three doors (#20316) #20416, feat(spec)!: FlowSchema refuses a create_record, update_record or delete_record node whose static objectName is a stored-metadata table, with the runtime's prescription (#21654) #21687).check-adr-0087-registration:[BREAKING+clause-②-narrowing] registered flow-approval-node-config-contract-refused.check:generatedpasses all 15 generated artifacts. None changed apart from the registry region, because the public exports did not change.Fixtures
These fixtures fed the narrowed rule. Each one was re-judged:
spec/.../flow-region-pause-and-end.test.ts: thepausingNode('approval')fixture had no config and is now refused for missingapprovers. Fix: it now declares the one key the contract requires,approvers.lint/src/runtime-gate.test.ts,metadata-protocol/src/protocol.runtime-authoring-gate.test.ts,objectql/src/plugin.authoring-channel.test.ts: the "clean" approval flow in all three is a copy of one worked example, and it carriedemptyApproverPolicy: 'reject'. That key was never declared by the contract, and the executor would have refused the node on every run. Fix: deleted the key. The flow is then the broken flow with its expression fixed, which is what those tests mean by "clean". These files are outside the original claim; claim revision round 1 covers them.spec/.../flow-slot-refusal-codes.test.ts: added a pin per new code and approval rows in the sweep. Its message pins follow the file's existing per-code convention.The one
domain:servicesfixture (patch round 1)packages/services/service-automation/src/engine.test.ts, test "says nothing about a type a plugin registered AFTER the flow": itsbaseFlow('approval')helper registered an approval node with noconfig.registerFlowrunsFlowSchema.parsefirst, so it now refused that node withnodes.1.config.approvers. The test is about sealing the node-type vocabulary, not about config. The claim revision covers this file. The only change is one line in the helper: anapprovalnode now getsconfig: { approvers: [{ type: 'user', value: 'u1' }] }. That is the same disposition aspausingNodeabove. Noservice-automationsource changed.service-automationnow passes 2110/2110.Relation to #21848 (#21848 remains open)
AutomationEngine.registerFlowrunsFlowSchema.parsebefore anything else (engine.ts:4348), so this PR also makes registration refuse approval values liketimeoutHours: 0.5, on every door that registers through it. That overlaps #21848's done-when and does not contradict it. Its seat should re-read what is left of its scope: package load paths that do not go throughFlowSchema, and its own registration pins. The stable reuse point is the exportedflowNodeConfigRefusals, not a second lookup.getBuiltinNodeConfigContracts().get('approval')returnsundefined.Verification (final union at
76118d27fe)Tests
@objectstack/spec: test 617 files / 18447 passed, exit 0; typecheck exit 0. Measured at3fc48ab07f; patch round 1 changed no spec file.@objectstack/lint: before the fixture fix, the full suite had 2 failures, both inruntime-gate. After the fix, that file passes 46/46.@objectstack/metadata-protocol: before the fixture fix, 3 of the 4 files with approval fixtures passed. After it, the fourth passes too:protocol.runtime-authoring-gate.test.ts70/70.@objectstack/objectql: the full suite at76118d27fepasses, 374 files / 7464 tests, exit 0.@objectstack/http-conformance: the full suite at76118d27fepasses, 8 files / 102 tests, exit 0.@objectstack/plugin-approvals: 895/895.@objectstack/service-automation: the full suite at76118d27fepasses, 172 files / 2110 tests, exit 0. Typecheck exit 0.@objectstack/cli:test/authoring-rule-command-parity.test.ts11/11, in its integration tier.Gates
dispatch-gates --ranat76118d27fe: 96 derived, 96 run, 0 NOT-MEASURED, 0 unrun. The patch round addscheck-tenant-audit-censusand its--self-test, and both pass.check:dual-build-cjs-loads: exit 0 after a full build supplied the 8 missingdist/folders. 106 require entry points across 66 packages load.check-engine-split-ratio --days 90: exit 2, refused on a shallow clone (oldest visible commit 2026-09-20). It is recorded as run, and its metric is not measured here.check:type-check-debt: exit 0, "none above its recorded number".ESLint, narrowed to the changed files and shown to cover them
isPathIgnoredreports all 12 changed.tsfiles as linted.--format jsonreports 12 files, 0 errors, 0 warnings, at76118d27fe.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change the result for any file it does not touch.Declared to CI: the full
pnpm lint, the dogfood suite (the census found all 6 dogfood approval fixtures accepted), and the rest of the cli integration tier.Acceptance notes (not filed)
flow-node-config.ts:996) writesconfig.escalation.enabled. ItstimeoutHoursfield only shows whileenabledis'true', so switching SLA escalation off can saveescalation: { enabled: false }with notimeoutHours. The contract refuses that, and the executor already refused it on every run; it is now refused at save, atescalation.timeoutHours. This comes from reading the source; I did not drive the designer. Owner: none.defineFlowthrows while the CLI loads its config, the CLI prints the raw ZodError JSON, with a path relative to the flow and no flow name or file. This is existing behaviour for everydefineFlowrefusal.plugin-approvalschecks the approval executor'ssafeParseagainst the declared map, the way the builtin ledger does for builtins. That check would live inplugin-approvals(domain:services). automation: an approval node's escalation values are checked only at execution — timeoutHours 0.5 registers and activates, then every run fails and the record is created with no approval gate #21848's PR is the natural place for it.Generated by Claude Code