Skip to content

Commit 866683f

Browse files
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)
Fixes #21850 Clause-②: yes (narrowing) The build doors now judge an `approval` node's `config` against the contract the spec declares for it, `ApprovalNodeConfigSchema`, whole. `escalation.bogusKey` and `escalation.timeoutHours: 0.5` are each refused with a location at `FlowSchema.parse`, `objectstack validate` and `objectstack compile`. Before this, both exited 0. The existing alias text ("did you mean `timeout` → `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:services` fixture line that the claim revision now covers (see "The one `domain:services` fixture" below). ## Two route changes, both measured before the edit The dispatch route was to put `approval` into `getBuiltinNodeConfigContracts` beside 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: 1. **The builtin map is reconciled 1:1 against the builtin executors.** `service-automation`'s `node-config-contract-ledger.test.ts` reads every `parseNodeConfig` call in its own `builtin/` sources. With `approval` in 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 is `domain:services` code, so this PR cannot change it. 2. **The judge only checks for missing keys.** `flowNodeConfigRefusals` keeps an issue only when the key it names is absent. The shipped `flow-node-config-required-keys-refused` entry says the same thing. With `approval` in the map, `FlowSchema` still accepted both card pins (`success: true`). Only an approval node with no `approvers` was refused. What this PR does instead: - **A declared contract map beside the builtin one.** `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.ts` imports nothing from `automation/` (zod, the membership-role leaf, `lazySchema`, `strictObject`), so no import cycle is added. - **That map is judged whole.** The approval executor (`plugin-approvals`, `approval-node.ts`) runs `safeParse` on `node.config` before it does anything else and fails the node on any issue, so every issue the contract raises is refused: - An undeclared key, or a refused value, gets the new closed-set code `node-config-refused-by-contract` with `params: { nodeType, key }`. It is anchored at the key: `escalation.bogusKey`, or one refusal per key for top-level keys. - The message wraps the contract's own sentence, including its did-you-mean. - A missing required key keeps `node-config-key-missing` or `node-config-key-required-by-rule`. - **The builtin arm does not change.** It still only checks for missing keys. A control test pins it: an `http` node with an undeclared key still parses. - **`getBuiltinNodeConfigContracts` keeps its export, shape and contents** (the 13 builtins). A caller who looks up `approval` gets `undefined`. 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 `config` with `ApprovalNodeConfigSchema.safeParse`. Non-literal configs were read by hand. Lit controls: the same search pattern finds `decision` nodes in `app-crm` and `app-todo`, which author flows but no approval nodes, and it finds the known showcase hit `dynamic-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/dogfood` fixtures: 6 nodes, accepted. - objectui at the pin `0abd4f9f87`, read-only: the designer's approval seed `defaultNodeExtras('approval')` (`{ approvers: [{ type: 'manager' }], behavior, lockRecord }`) is accepted. objectui's `flow-canvas-seeds.spec-parse.test.tsx` parses seeds with `FlowNodeSchema`, which never calls this judge. `flow-required-keys.ts` asks the judge only whether some refusal names the probed path, and nothing here changes that answer for a missing key. - hotcrm: **NOT MEASURED**, because the session's permission check denied the read-only clone. No real writer is refused. In test code, 5 fixtures needed changes. They are listed under "Fixtures" and under "The one `domain:services` fixture". ## Reproduction, before and after (a scratch copy of the showcase) I added an `escalation` block to the `co_sign` approval node in `dynamic-approval.flow.ts`. The script proved each edit landed on disk and restored the file byte for byte afterwards. | variant | `5e0b489bca` (main) | this branch | |:--|:--|:--| | control `{ timeoutHours: 2, action: 'notify' }` | validate 0 · compile 0 | validate 0 · compile 0 | | `{ timeoutHours: 2, action: 'notify', bogusKey: 1 }` | validate 0 (`✓ Validation passed`) · compile 0 (`✓ Build complete`), `bogusKey` written to `dist/objectstack.json` | validate 1 · compile 2, `custom` at `nodes.2.config.escalation.bogusKey` | | `{ timeoutHours: 0.5, action: 'notify' }` | validate 0 · compile 0, `0.5` written to the artifact | validate 1 · compile 2, `custom` at `nodes.2.config.escalation.timeoutHours` | Branch wording at the validate door: "This `approval` node's config is refused at `escalation.bogusKey` by the approval contract: Unrecognized key(s) on this approval escalation: `bogusKey`. …". For the `timeout` alias, the contract's "Did you mean `timeout` → `timeoutHours`?" is carried through, next to the `escalation.timeoutHours` key-missing refusal. ## Doors pinned (`flow-approval-node-config-contract.test.ts`) Each door has a valid approval node as its control: - `FlowSchema` refuses both pins, and the alias, top-level undeclared keys, a missing `approvers` and a rule finding (`onEmptyApprovers: 'fail'` together with `fallbackApprovers`). - A sweep checks that the judge refuses exactly what the contract refuses. - `defineStack` refuses with `STACK_SCHEMA_INVALID` / 422 at `flows.1.nodes.1.config.escalation.bogusKey`. - `ObjectStackDefinitionSchema` refuses. This is the stack parse that validate and compile run. - The registered `flow` type schema used by the metadata save door refuses. - The artifact parse refuses. - The `validateStackExpressions` door shows up in `@objectstack/lint`'s run as `flow 'leave_approval' · node 'approve' (approval) config.emptyApproverPolicy`. **Ablation.** I committed the fix first, then deleted the `[APPROVAL_NODE_TYPE, …]` entry with `scripts/ablation-replace.mjs`: anchor count 1 → 0, blob `f915eb58bcd0` → `90e86f48dcc4`. The subject resolves through `src` by relative import, so no build was involved. - Prediction: 14 red, made up of 12 refusal tests in the new file plus 2 in `flow-slot-refusal-codes.test.ts` (the new code's pin, and "every code is reached"). - Result: `Tests 14 failed | 26 passed (40)`, matching the prediction. - Restore: blob equal to HEAD and `git diff HEAD` empty. ## The ADR-0087 kit - **D3 entry.** `entries/semantic/18.flow-approval-node-config-contract-refused.ts`, with its `registry.ts` region regenerated by `gen:migration-registry`. - **Step-18 rationale.** A fragment at **order 84**. I re-read `origin/main` at `e085a8c3be` before opening this PR, and again at `67c544ccca` in patch round 1: the highest order there is 83, and this id is absent. If #21829 or #21848 also takes 84, the two fragments render in id order, as the registry header allows. - **No tombstone and no D2 conversion.** No key is removed, and a refused node holds no intent that a rewrite could keep. - **Changeset.** One BREAKING `minor` changeset for `@objectstack/spec` with the `registered` marker and the `Clause-②` line, at the level the precedents set (#20416, #21687). `check-adr-0087-registration`: `[BREAKING+clause-②-narrowing] registered flow-approval-node-config-contract-refused`. - **Regeneration.** `check:generated` passes 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`: the `pausingNode('approval')` fixture had no config and is now refused for missing `approvers`. 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 carried `emptyApproverPolicy: '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:services` fixture (patch round 1) `packages/services/service-automation/src/engine.test.ts`, test "says nothing about a type a plugin registered AFTER the flow": its `baseFlow('approval')` helper registered an approval node with no `config`. `registerFlow` runs `FlowSchema.parse` first, so it now refused that node with `nodes.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: an `approval` node now gets `config: { approvers: [{ type: 'user', value: 'u1' }] }`. That is the same disposition as `pausingNode` above. No `service-automation` source changed. `service-automation` now passes 2110/2110. ## Relation to #21848 (#21848 remains open) `AutomationEngine.registerFlow` runs `FlowSchema.parse` before anything else (`engine.ts:4348`), so this PR also makes registration refuse approval values like `timeoutHours: 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 through `FlowSchema`, and its own registration pins. The stable reuse point is the exported `flowNodeConfigRefusals`, not a second lookup. `getBuiltinNodeConfigContracts().get('approval')` returns `undefined`. ## Verification (final union at `76118d27fe`) **Tests** - `@objectstack/spec`: test 617 files / 18447 passed, exit 0; typecheck exit 0. Measured at `3fc48ab07f`; patch round 1 changed no spec file. - `@objectstack/lint`: before the fixture fix, the full suite had 2 failures, both in `runtime-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.ts` 70/70. - `@objectstack/objectql`: the full suite at `76118d27fe` passes, 374 files / 7464 tests, exit 0. - `@objectstack/http-conformance`: the full suite at `76118d27fe` passes, 8 files / 102 tests, exit 0. - `@objectstack/plugin-approvals`: 895/895. - `@objectstack/service-automation`: the full suite at `76118d27fe` passes, 172 files / 2110 tests, exit 0. Typecheck exit 0. - `@objectstack/cli`: `test/authoring-rule-command-parity.test.ts` 11/11, in its integration tier. - Typecheck for lint, objectql and metadata-protocol: exit 0 each. **Gates** - `dispatch-gates --ran` at `76118d27fe`: 96 derived, 96 run, 0 NOT-MEASURED, 0 unrun. The patch round adds `check-tenant-audit-census` and its `--self-test`, and both pass. - `check:dual-build-cjs-loads`: exit 0 after a full build supplied the 8 missing `dist/` 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** 1. Population: ESLint's own `isPathIgnored` reports all 12 changed `.ts` files as linted. 2. Count: `--format json` reports 12 files, 0 errors, 0 warnings, at `76118d27fe`. 3. Untouched files: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.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) - objectui's flow inspector (`flow-node-config.ts:996`) writes `config.escalation.enabled`. Its `timeoutHours` field only shows while `enabled` is `'true'`, so switching SLA escalation off can save `escalation: { enabled: false }` with no `timeoutHours`. The contract refuses that, and the executor already refused it on every run; it is now refused at save, at `escalation.timeoutHours`. This comes from reading the source; I did not drive the designer. Owner: none. - When `defineFlow` throws 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 every `defineFlow` refusal. - Nothing in `plugin-approvals` checks the approval executor's `safeParse` against the declared map, the way the builtin ledger does for builtins. That check would live in `plugin-approvals` (`domain:services`). #21848's PR is the natural place for it. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b238856 commit 866683f

13 files changed

Lines changed: 677 additions & 13 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A flow `approval` node's `config` is judged at parse against the contract the spec declares for it, `ApprovalNodeConfigSchema`, whole: an undeclared key, a refused value and a required key left out are each refused with a location, in the contract's own words.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered flow-approval-node-config-contract-refused -->
10+
11+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**Why.** The approval node's executor parses `node.config` against `ApprovalNodeConfigSchema` before it does anything else and fails the node on any issue. Registration already refused an undeclared key, but a refused value such as `escalation.timeoutHours: 0.5` registered and then failed every run that reached the node, and no build door asked about either: `objectstack validate` and `objectstack compile` exited 0 on an `escalation.bogusKey` or a `timeoutHours: 0.5`, and compile copied it into `dist/objectstack.json`.
14+
15+
**What is refused.** An `approval` node, at any depth, whose `config` the approval contract refuses. The judge is `flowNodeConfigRefusals`, the one `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share; the approval contract joins it as a declared contract map beside the builtin executor contracts, with no plugin loaded. Every issue that contract raises is refused, because the executor refuses on every one:
16+
17+
- an undeclared key, at the key (`nodes.N.config.escalation.bogusKey`, one issue per key, the top level included), and a refused value, at its key (`nodes.N.config.escalation.timeoutHours` for `0.5` under its minimum of 1): the new closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, whose message carries the contract's own sentence — for an alias, its did-you-mean (`timeout` → `timeoutHours`);
18+
- a required key left out (`approvers`; `timeoutHours` inside an `escalation` block): `node-config-key-missing`, as for a builtin node, or `node-config-key-required-by-rule` where a rule of the contract requires it.
19+
20+
The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.<key>`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`).
21+
22+
**What stays accepted, byte for byte.** Every approval node the contract accepts, an `approval_revise` node, and every builtin node: the builtin arm still judges only a key left out, so an undeclared key or a wrong-typed value on a builtin node is judged where it was before. A plugin node type whose contract the spec does not declare stays outside the build doors.
23+
24+
## FROM → TO
25+
26+
| you wrote | write instead |
27+
|:--|:--|
28+
| `escalation: { …, bogusKey: 1 }`, or any key the contract does not declare | delete the key, or rename it to the one the refusal's did-you-mean names (`timeout` → `timeoutHours`, `mode` → `behavior`, `quorum` → `minApprovals`) |
29+
| `escalation: { timeoutHours: 0.5 }` | `escalation: { timeoutHours: 1 }` — whole wall-clock hours, at least 1 |
30+
| `escalation: { enabled: false }` with no `timeoutHours` | delete the `escalation` block |
31+
| `steps`, `entryCriteria`, `onApprove`, `onReject` or `rejectionBehavior` on the node | the flow graph, as the refusal's guidance says (successive nodes, the entering edge's `condition`, the `approve` / `reject` out-edges, a back-edge) |
32+
| an approval node with no `approvers` | `approvers: [{ type: 'position', value: '<position>' }]` (or any approver the contract accepts) |
33+
34+
**The one-line fix: write the shape the approval contract declares at the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does.
35+
36+
**Who is affected, measured.** At `5e0b489bca`, every approval node `config` authored in this repository parses under the contract: `examples/**` (15 nodes, all in the showcase), `content/docs/**` (6 snippets), `skills/**` (5 snippets) and the `packages/qa/dogfood` fixtures (6 nodes), and so does the Studio designer's approval seed at the pinned objectui commit. Deployed metadata, and repositories other than these two, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register.
37+
38+
### The kit
39+
40+
- **The refusal.** The declared contract map in `automation/flow-node-config-refusals.ts`, read by the same executor-contract arm of `flowNodeConfigRefusals`; the new code joins `FLOW_SLOT_REFUSAL_CODES`.
41+
- **The ledger.** The D3 semantic entry `flow-approval-node-config-contract-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know the approvers, the key or the value the author meant.

‎packages/lint/src/runtime-gate.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ const cleanApprovalFlow = {
4545
type: 'approval',
4646
config: {
4747
approvers: [{ type: 'expression', value: 'current.owner' }],
48-
emptyApproverPolicy: 'reject',
4948
},
5049
},
5150
],

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ const validApprovalFlow = () => {
7070
const flow = brokenApprovalFlow();
7171
flow.nodes[1]!.config = {
7272
approvers: [{ type: 'expression', value: 'current.owner' }],
73-
emptyApproverPolicy: 'reject',
7473
} as any;
7574
return flow;
7675
};

‎packages/objectql/src/plugin.authoring-channel.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,6 @@ describe('#6710 — the authoring channel is threaded from plugin option to prot
274274
const flow = brokenApprovalFlow();
275275
(flow.nodes[1] as any).config = {
276276
approvers: [{ type: 'expression', value: 'current.owner' }],
277-
emptyApproverPolicy: 'reject',
278277
};
279278
const result = await (kernel.getService('protocol') as any).saveMetaItem({
280279
type: 'flow', name: 'leave_approval', item: flow,

‎packages/services/service-automation/src/engine.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2626,7 +2626,7 @@ describe('Action Descriptor Registry (ADR-0018)', () => {
26262626
type: 'autolaunched' as const,
26272627
nodes: [
26282628
{ id: 'start', type: 'start', label: 'Start' },
2629-
{ id: 'custom', type, label: 'Custom' },
2629+
{ id: 'custom', type, label: 'Custom', ...(type === 'approval' ? { config: { approvers: [{ type: 'user', value: 'u1' }] } } : {}) },
26302630
{ id: 'end', type: 'end', label: 'End' },
26312631
],
26322632
edges: [
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21850] The build doors judge an `approval` node's `config` against the
5+
* contract the spec declares for it, `ApprovalNodeConfigSchema`, WHOLE — the
6+
* declared contract map in `flow-node-config-refusals.ts`, beside the builtin
7+
* executor contracts and read by the same judge, `flowNodeConfigRefusals`.
8+
*
9+
* The approval executor (`plugin-approvals`) parses `node.config ?? {}`
10+
* against that contract before it does anything else and fails the node on
11+
* ANY issue, so every contract finding is one the run would refuse: a key the
12+
* contract requires left out, a key it does not declare, a value it refuses.
13+
* `FlowSchema` used to accept all three, so `objectstack validate` and
14+
* `objectstack compile` exited 0 on them and compile copied the shape into the
15+
* artifact; the author learned otherwise at the first run.
16+
*
17+
* Every door that parses a flow meets the judge: `FlowSchema` itself,
18+
* `defineStack`, the stack parse `objectstack validate` and `compile` run, the
19+
* registered `flow` type schema the metadata save door validates against, and
20+
* an artifact's parse — pinned here. `registerFlow` parses first, and
21+
* `validateStackExpressions` calls the same judge.
22+
*
23+
* No plugin is loaded for any of it: the contract is the spec's own. The
24+
* builtin arm is unchanged, presence-only — a control below holds it there.
25+
*/
26+
27+
import { describe, expect, it } from 'vitest';
28+
29+
import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas';
30+
import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry';
31+
import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod';
32+
import { ApprovalEscalationSchema, ApprovalNodeConfigSchema } from './approval.zod';
33+
import { flowNodeConfigRefusals, getBuiltinNodeConfigContracts } from './flow-node-config-refusals';
34+
import { FlowSchema } from './flow.zod';
35+
36+
const ENTRY_ID = 'flow-approval-node-config-contract-refused';
37+
38+
type Config = Record<string, unknown>;
39+
40+
const APPROVERS = [{ type: 'position', value: 'finance_reviewer' }];
41+
42+
/** A whole approval config the contract accepts — the accept control, and the base every probe edits. */
43+
const VALID: Config = {
44+
approvers: APPROVERS,
45+
behavior: 'first_response',
46+
lockRecord: true,
47+
escalation: { enabled: true, timeoutHours: 4, action: 'notify', notifySubmitter: true },
48+
};
49+
50+
const withEscalation = (escalation: Config): Config => ({ ...VALID, escalation });
51+
52+
/** start → the approval node → approve / reject ends. */
53+
function flowWith(config: unknown, name = 'approval_probe') {
54+
return {
55+
name,
56+
label: 'Approval probe',
57+
type: 'autolaunched',
58+
nodes: [
59+
{ id: 'start', type: 'start', label: 'Start' },
60+
{ id: 'gate', type: 'approval', label: 'Gate', ...(config === undefined ? {} : { config }) },
61+
{ id: 'approved', type: 'end', label: 'Approved' },
62+
{ id: 'rejected', type: 'end', label: 'Rejected' },
63+
],
64+
edges: [
65+
{ id: 'e1', source: 'start', target: 'gate' },
66+
{ id: 'e2', source: 'gate', target: 'approved', label: 'approve' },
67+
{ id: 'e3', source: 'gate', target: 'rejected', label: 'reject' },
68+
],
69+
};
70+
}
71+
72+
interface IssueSig { code: string; path: string; message: string }
73+
74+
function issuesOf(flow: unknown): IssueSig[] {
75+
const r = FlowSchema.safeParse(flow);
76+
return r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }));
77+
}
78+
79+
/** The approval contract's own sentence at one issue path — read, never re-spelled. */
80+
function contractSentence(schema: { safeParse(v: unknown): { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }, value: unknown, path: string): string {
81+
const own = schema.safeParse(value);
82+
return own.success ? '' : own.error?.issues.find((i) => i.path.join('.') === path)?.message ?? '';
83+
}
84+
85+
describe('FlowSchema judges an approval node config against its declared contract, whole', () => {
86+
it('an undeclared escalation key is refused at nodes.1.config.escalation.bogusKey', () => {
87+
const config = withEscalation({ timeoutHours: 2, action: 'notify', bogusKey: 1 });
88+
const issues = issuesOf(flowWith(config));
89+
expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([
90+
{ code: 'custom', path: 'nodes.1.config.escalation.bogusKey' },
91+
]);
92+
// The judge's own words, never re-spelled, carrying the contract's own sentence.
93+
expect(issues[0]!.message).toBe(flowNodeConfigRefusals('approval', config)[0]!.message);
94+
expect(issues[0]!.message).toContain(contractSentence(ApprovalEscalationSchema, config.escalation, ''));
95+
});
96+
97+
it('a timeoutHours under the contract minimum is refused at nodes.1.config.escalation.timeoutHours', () => {
98+
const issues = issuesOf(flowWith(withEscalation({ timeoutHours: 0.5, action: 'notify' })));
99+
expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([
100+
{ code: 'custom', path: 'nodes.1.config.escalation.timeoutHours' },
101+
]);
102+
});
103+
104+
it('the judge answers both with its code, params and path', () => {
105+
const bogus = flowNodeConfigRefusals('approval', withEscalation({ timeoutHours: 2, bogusKey: 1 }));
106+
const halfHour = flowNodeConfigRefusals('approval', withEscalation({ timeoutHours: 0.5 }));
107+
expect([...bogus, ...halfHour].map(({ code, params, path, source }) => ({ code, params, path, source }))).toEqual([
108+
{ code: 'node-config-refused-by-contract', params: { nodeType: 'approval', key: 'escalation.bogusKey' }, path: 'escalation.bogusKey', source: '' },
109+
{ code: 'node-config-refused-by-contract', params: { nodeType: 'approval', key: 'escalation.timeoutHours' }, path: 'escalation.timeoutHours', source: '' },
110+
]);
111+
});
112+
113+
it('an alias keeps the contract\'s own did-you-mean, beside the key it leaves out', () => {
114+
const config = withEscalation({ timeout: 2 });
115+
const issues = issuesOf(flowWith(config));
116+
expect(issues.map(({ path }) => path).sort()).toEqual([
117+
'nodes.1.config.escalation.timeout',
118+
'nodes.1.config.escalation.timeoutHours',
119+
]);
120+
const aliasSentence = contractSentence(ApprovalEscalationSchema, config.escalation, '');
121+
expect(aliasSentence).toContain('`timeoutHours`');
122+
expect(issues.find((i) => i.path.endsWith('.timeout'))!.message).toContain(aliasSentence);
123+
});
124+
125+
it('an undeclared top-level key is refused at the key, one refusal per key', () => {
126+
const issues = issuesOf(flowWith({ ...VALID, steps: [], quorum: 2 }));
127+
expect(issues.map(({ path }) => path)).toEqual(['nodes.1.config.steps', 'nodes.1.config.quorum']);
128+
});
129+
130+
it('a key the contract requires, left out, is refused with the builtin arm\'s code', () => {
131+
for (const config of [undefined, {}]) {
132+
expect(issuesOf(flowWith(config)).map(({ path }) => path), JSON.stringify(config)).toEqual(['nodes.1.config.approvers']);
133+
expect(flowNodeConfigRefusals('approval', config).map(({ code }) => code)).toEqual(['node-config-key-missing']);
134+
}
135+
});
136+
137+
it('a value a rule of the contract refuses is refused in the rule\'s own words', () => {
138+
const config = { ...VALID, onEmptyApprovers: 'fail', fallbackApprovers: APPROVERS };
139+
const [refusal] = flowNodeConfigRefusals('approval', config);
140+
expect(refusal!.path).toBe('onEmptyApprovers');
141+
expect(refusal!.message).toContain(contractSentence(ApprovalNodeConfigSchema, config, 'onEmptyApprovers'));
142+
});
143+
144+
it('the judge refuses exactly what the contract refuses, over a sweep of configs', () => {
145+
const sweep: unknown[] = [
146+
undefined, {}, VALID,
147+
{ approvers: [] },
148+
{ approvers: 'u1' },
149+
{ approvers: [{ type: 'user', value: 'u1' }] },
150+
{ ...VALID, behavior: 'weighted' },
151+
{ ...VALID, minApprovals: 0 },
152+
{ ...VALID, maxRevisions: 1.5 },
153+
{ ...VALID, decisionOutputs: ['note', { key: 'picked', type: 'user', multiple: true }] },
154+
withEscalation({ timeoutHours: 1 }),
155+
withEscalation({ enabled: false }),
156+
withEscalation({ timeoutHours: 2, action: 'escalate' }),
157+
withEscalation({ timeoutHours: '2' }),
158+
];
159+
for (const config of sweep) {
160+
const refused = flowNodeConfigRefusals('approval', config).length > 0;
161+
expect(refused, JSON.stringify(config)).toBe(!ApprovalNodeConfigSchema.safeParse(config ?? {}).success);
162+
}
163+
});
164+
});
165+
166+
describe('what stays accepted (lit controls)', () => {
167+
it('CONTROL: a valid approval node parses', () => {
168+
expect(issuesOf(flowWith(VALID))).toEqual([]);
169+
expect(flowNodeConfigRefusals('approval', VALID)).toEqual([]);
170+
});
171+
172+
it('CONTROL: an escalation at the contract minimum, and a node with no escalation block, parse', () => {
173+
expect(issuesOf(flowWith(withEscalation({ timeoutHours: 1 })))).toEqual([]);
174+
expect(issuesOf(flowWith({ approvers: APPROVERS }))).toEqual([]);
175+
});
176+
177+
it('CONTROL: the builtin arm stays presence-only — an undeclared key on a builtin node still parses', () => {
178+
const flow = {
179+
...flowWith(VALID),
180+
nodes: [
181+
{ id: 'start', type: 'start', label: 'Start' },
182+
{ id: 'call', type: 'http', label: 'Call', config: { url: 'https://example.test', bogusKey: 1 } },
183+
{ id: 'done', type: 'end', label: 'Done' },
184+
],
185+
edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'done' }],
186+
};
187+
expect(issuesOf(flow)).toEqual([]);
188+
// …and the builtin map, the executor-reconciled one, did not gain the plugin type.
189+
expect(getBuiltinNodeConfigContracts().has('approval')).toBe(false);
190+
});
191+
192+
it('CONTROL: approval_revise carries no config contract and is not judged', () => {
193+
expect(flowNodeConfigRefusals('approval_revise', { anything: 1 })).toEqual([]);
194+
});
195+
});
196+
197+
describe('every door that parses a flow refuses it', () => {
198+
const stackWith = (flows: unknown[]) => ({
199+
manifest: { id: 'com.example.approvals', name: 'approvals', version: '1.0.0', type: 'app', namespace: 'apv' },
200+
objects: [{ name: 'apv_request', label: 'Request', fields: { title: { type: 'text', label: 'Title' } } }],
201+
flows,
202+
});
203+
const refused = flowWith(withEscalation({ timeoutHours: 2, bogusKey: 1 }), 'apv_refused');
204+
const accepted = flowWith(VALID, 'apv_ok');
205+
206+
it('defineStack wraps the refusal in its ADR-0112 envelope, at flows.N.nodes.1.config.escalation.bogusKey', () => {
207+
let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined;
208+
try {
209+
defineStack(stackWith([accepted, refused]) as never);
210+
} catch (e) {
211+
refusal = e as typeof refusal;
212+
}
213+
expect(refusal, 'defineStack must refuse the approval flow').toBeDefined();
214+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 });
215+
expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([
216+
{ path: 'flows.1.nodes.1.config.escalation.bogusKey', code: 'custom' },
217+
]);
218+
});
219+
220+
it('CONTROL: defineStack accepts the valid approval flow alone', () => {
221+
expect(() => defineStack(stackWith([accepted]) as never)).not.toThrow();
222+
});
223+
224+
it('ObjectStackDefinitionSchema — the stack parse validate and compile run — refuses it at the same path', () => {
225+
const r = ObjectStackDefinitionSchema.safeParse(stackWith([flowWith(withEscalation({ timeoutHours: 0.5 }), 'apv_half')]));
226+
expect(r.success).toBe(false);
227+
expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.escalation.timeoutHours']);
228+
expect(ObjectStackDefinitionSchema.safeParse(stackWith([accepted])).success).toBe(true);
229+
});
230+
231+
it('the registered `flow` type schema — what the metadata save door validates against — refuses it too', () => {
232+
const schema = getMetadataTypeSchema('flow') as unknown as typeof FlowSchema;
233+
expect(schema).toBeDefined();
234+
const r = schema.safeParse(refused);
235+
expect(r.success).toBe(false);
236+
expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.escalation.bogusKey']);
237+
expect(schema.safeParse(accepted).success).toBe(true);
238+
});
239+
240+
it('an artifact\'s parse refuses it', () => {
241+
const body = { id: 'com.example.approvals', name: 'approvals', version: '1.0.0', type: 'app' };
242+
const r = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [refused] });
243+
expect(r.success).toBe(false);
244+
expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.escalation.bogusKey']);
245+
const ok = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [accepted] });
246+
expect(ok.success, JSON.stringify(ok.error?.issues ?? [])).toBe(true);
247+
});
248+
});
249+
250+
describe('the ADR-0087 ledger', () => {
251+
it('registers one D3 entry at protocol 18, with no D2 conversion', () => {
252+
const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((e) => e.id === ENTRY_ID);
253+
expect(entries, 'the narrowing needs its own D3 entry').toHaveLength(1);
254+
const [entry] = entries;
255+
expect(entry!.conversionIds ?? []).toEqual([]);
256+
expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0);
257+
});
258+
259+
it('registers no tombstone: no approval config key is removed', () => {
260+
const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat();
261+
expect(all.filter((k) => /Approval(NodeConfig|Escalation)[^:]*:/.test(k))).toEqual([]);
262+
// CONTROL: the flattened table is the real one — it carries a known step-18 tombstone.
263+
expect(all).toContain('api/RestApiEndpoint:timeout');
264+
});
265+
});

0 commit comments

Comments
 (0)