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