Skip to content

Commit 733822c

Browse files
fix(spec): refuse decision mode beside a non-empty conditions list (#20168) (#20279)
Fixes #20168 Clause-②: no This PR carries ruling `5856786357` into `packages/spec`: batch #227 item 4, letter **A**, put in force by `5856865124`. A `decision` node that declares a NON-EMPTY `conditions` list together with `mode` is now refused at authoring. The refusal names the ruled way out: drop `mode` (a conditions list is first-match on its own), or move the branches onto the edges and keep `mode`. `mode` belongs to the edge-branched decision alone. Dispatched by the `domain:spec` seat 4 PM, session `session_01CiCTczDo7tGhafXjf61dUJ`. The claim is comment `5857419814`. Branch base `e46218674`; every reading below is at head `ae8185032` unless it says otherwise. ## What changes 1. **`DecisionConfigSchema`** (`packages/spec/src/automation/schemaless-node-config.zod.ts`) gains a `.superRefine`. When `mode` is authored beside a non-empty `conditions` array, it adds one `custom` issue at `['mode']`. The message comes from a new helper, `decisionModeWithConditionsRefusal()`, which sits beside the existing `decisionModePrescription()` and echoes the value the same way. Both members are refused alike, because it is the key that has no reader here, not the value. 2. Out of scope, by the ruling: `mode` beside an empty list, `mode` with `conditions` absent, and a list without `mode`. All three parse exactly as before. A `mode` outside the closed pair still gets its own value refusal first. A base-type issue aborts the object's refinement, so the author sees one issue, never two. 3. The `mode` `.describe()` and docblocks now state the refusal. `content/docs/references/automation/schemaless-node-config.mdx` is regenerated with `gen:docs`, because `check:generated` proved only `check:docs` stale. 4. **`dropped-refinements.baseline.json`** gains exactly the row the build's ratchet printed: `automation/DecisionConfig`, with one root site (the empty path). No arm of the projection list in `shared/refinement-projection.ts` fits "this key forbids that one when the list is non-empty". That rule is value-conditional, and `dependent-required` / `banned-keys` are presence-only. Adding an arm would be a public-contract decision, so the one route the ruling allows was taken. The header totals were recounted from the body: 211 → **212** schemas and 604 → **605** sites. The built `json-schema/automation/DecisionConfig.json` carries `x-dropped-refinements: [{ at: '', type: 'object', count: 1 }]`. 5. The changeset `.changeset/20168-decision-mode-beside-conditions-refused.md` bumps `@objectstack/spec` as `patch`, with `Clause-②: no`. ## Timing and grade: measured at landing, not assumed (ruling item 2) | reading | value | source | |:--|:--|:--| | npm `latest` `@objectstack/spec` | `17.4.0` | `npm view @objectstack/spec dist-tags`, 2026-09-27T16:12Z, and again at 18:03Z | | `mode` in the published contract | **absent**: `json-schema/automation/DecisionConfig.json` in the 17.4.0 tarball declares `conditions` only, with `additionalProperties: false`. In `dist/automation/index.js`, the control string `first true expression wins` has 2 hits and the test string `edge-branched decision` has 0 | `npm pack @objectstack/spec@17.4.0` | | Version Packages PR #17076 | `open`, `merged: false` | REST, 16:12Z and 18:03Z | | pending changeset for `mode` | `.changeset/19867-decision-config-mode.md` is still in `.changeset/`, so it is unconsumed | tree at `ae8185032` | ⇒ **Unreleased.** Per ruling item 2 this is `patch`, `Clause-②: no`, and no ADR-0087 entry. `mode` reaches its first release together with this refusal, so no published accept set narrows. Against the published 17.4.0 contract, the release still only widens. The same reading and its source are written into the changeset. ## Which doors parse `DecisionConfigSchema` today (PM mechanism assumption 3, measured) - **Direct parse**: yes, through the export and through the `SCHEMALESS_NODE_CONFIG_SCHEMAS.decision` handle (one object). Both are pinned. - **Flow registration**: no. `validateNodeConfigKeys` (`engine.ts`) skips a node whose descriptor publishes no `configSchema` (`if (!schema) continue;`), and `decision` publishes none by design. - **`FlowSchema` / `defineFlow`**: no. `FlowNodeSchema.config` is a `z.record(z.string(), z.unknown())`, and the node-level config pass parses only an `end` node (`parseEndNodeConfig`). - **`os validate`**: no. `lint-flow-patterns.ts` reads `config.conditions` ad hoc (labels, emptiness) and never parses the schema. `git grep` on `DecisionConfigSchema|SCHEMALESS_NODE_CONFIG_SCHEMAS|getSchemalessNodeConfigJsonSchemas` outside `packages/spec` finds three places. `metadata-protocol/src/reference-sites.ts` is a JSON-projection walk, where a refinement projects byte-identically. `service-automation`'s `config-expression-ledger.test.ts` reads the projection. `config-expression-ledger.test.ts:325` mentions the schema in a comment only. - **Published JSON Schema**: it cannot state the rule. The rule is declared dropped instead, in the ledger and on the artifact, as item 4 above describes. ⇒ No door that answers in the ADR-0112 envelope (a `code` and a `status`) parses this schema yet. The envelope arrives with the registration-time reader that #15429 adds, which is the ruling's item 3. This PR pins the parse door, the by-node-type registry handle that reader will look up, and the per-parse `objectStackErrorMap` a validator may pass. Mechanism assumption 1 held (`:471` / `:486` / `:206` on `e46218674`). So did assumption 2: the projection drops the refinement, and the ledger row is registered. ## For #15429's acceptance list (the `domain:services` seat) The refusal that the registration reader must surface: - issue `code: 'custom'`, `path: ['mode']`; exactly one issue for a config with a legal `mode` beside a non-empty `conditions`. - message first sentence, verbatim (the value is echoed): ``` `mode: 'inclusive'` is not valid on a decision that declares a `conditions` list — `mode` belongs to the edge-branched decision alone. ``` - the two remedies in the same message: ``Either delete `mode` and keep the list`` … ``move the branches onto the out-edges (a `condition` on each branch edge, `isDefault: true` on the fallback), delete `conditions`, and keep `mode`.`` - the message carries no tracker number. - to leave alone: `{ conditions: [], mode }`, `{ mode }`, and `{ conditions: [...] }` without `mode`. ## Pins, and the ablation `packages/spec/src/automation/schemaless-node-config.test.ts`: - The old pin *"…and alongside a branch list, which the key does not forbid"* asserted the accept-both shape. It is replaced by the ruled semantics. - A new describe block covers: - `{ conditions: [one], mode: 'inclusive' | 'exclusive' }` and the same with a two-entry list: refused, one `custom` issue at `['mode']`, ruled first sentence and both remedies. - The same refusal through `SCHEMALESS_NODE_CONFIG_SCHEMAS.decision`, and under `objectStackErrorMap`. - An illegal value beside a list: the value refusal only. - Controls, each a full `safeParse` success that round-trips: `{ conditions: [], mode }` for both members, `{ mode }` with `conditions` absent for both members, and a list without `mode`. Also a test that following either remedy parses. **Ablation** (one-shot, run from the committed state; no permanent test file). The test imports the schema by relative path, so the source is what is resolved and no `dist/` is in the path. `node scripts/ablation-replace.mjs` replaced the refinement's `if (...)` guard with `if (false)`: - mutation: anchor 1 → 0, blob `70f2ae5d5b01` → `1de6a6511010`; - run: **7 failed / 43 passed (50)**. All six refusal pins went red, plus *following either remedy parses*, whose first assertion is the refusal. The controls and the value-refusal-first test stayed green. That is the expected direction, and it was observed; - restore: blob back to `70f2ae5d5b01` == HEAD blob, `git diff HEAD` empty. ## Flipped-semantics sweep (card clause) No fixture, example, doc or skill authors `conditions` + `mode` together. The sweep grepped for `mode: 'inclusive'|'exclusive'` and the double-quoted forms: - `examples/**`, `skills/**` and `content/docs/**`: 0 hits. The six files carrying `type: 'decision'` were checked for any `mode:`, and the only two hits are a screen node's `mode: 'create'` and a comment. - `packages/services`, `packages/lint`, `packages/cli`, `packages/metadata`, `packages/metadata-protocol`: 0 hits. - `/home/user/hotcrm` at `2f7b2326` (read-only): 0 hits across its 14 decision-bearing files. The control `isDefault` hits. objectui was not checked out in this container, so its designer form is **NOT MEASURED** here. objectui#10750 stays the coordination card (ruling item 4). ## Verification at `ae8185032` | command | result | |:--|:--| | `pnpm --filter @objectstack/spec build` | exit 0; the dropped-refinement ratchet passes with the new row. Without the row it printed `+ automation/DecisionConfig (1 site(s))` and exited 1 | | `pnpm --filter @objectstack/spec test` | exit 0: 549 files, 16159 passed, 2 todo | | `pnpm --filter @objectstack/spec typecheck` | exit 0 (`tsc`, `check:scripts-typecheck`, `check:test-typecheck`) | | `pnpm --filter @objectstack/spec check:generated` | 14/15 current plus `check:docs` stale. After `gen:docs`, `check:docs` gives exit 0, "226 generated files in sync" | | consumer closure `turbo run build --only` (service-automation / lint / metadata-protocol closures, plus client and client-react, excluding spec) | exit 0 | | `@objectstack/service-automation` vitest | exit 0: 146 files, 1757 passed | | `@objectstack/lint` vitest | exit 0: 110 files, 4258 passed | | `@objectstack/metadata-protocol` vitest | exit 0: 189 passed, 3 skipped files; 2715 passed, 19 skipped | | `dispatch-gates --commands` → each run → `--ran` | 107 derived, **105 exit 0**, 2 NOT MEASURED, 0 unrun | | eslint (`--no-inline-config --format json`) on the 2 touched `.ts` files | exit 0, 2 files, 0 errors, 0 warnings | | `check:nul-bytes` plus a control-byte self-scan of the 4 hand-edited files | exit 0; 0 matches | - **NOT MEASURED, declared.** `check:dual-build-cjs-loads` and `check:type-check-debt` both exited 3 (PREREQUISITE NOT MET): they need every workspace package built, which is 86 packages without `dist` here, and lint.yml's own prerequisite is a full `turbo build` of all packages. CI runs both on the PR. - **Consumer sweep direction.** I ran the direct importers of the schema family found by `git grep` (upstream of nothing; downstream of spec), plus `@objectstack/lint` as the dispatch named it. I did not run all of `...@objectstack/spec`: the public types are byte-unchanged (`check:api-surface` exit 0 with no regeneration; `z.input`/`z.infer` are unaffected by a refinement), so only the parse accept set of this one schema narrows. - **eslint narrowing.** The population is the two `.ts` files. The `.json`, `.md` and `.mdx` files match no eslint config object. The count comes from the JSON output. It is invariant for untouched files, because `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`). ## Acceptance notes - The pending #19867 changeset still says "A `conditions` list is unaffected". It is left as written, because it is another PR's input. This PR's changeset states the refusal, and both reach the same release's CHANGELOG. If the release compiler wants one sentence, the edit is to append "— and `mode` beside a non-empty list is refused" to that bullet. - Ledger contention: PR #20251 also edits `dropped-refinements.baseline.json`. `origin/main` `17bd31877` has not moved the ledger since `e46218674`. Whichever PR lands second re-merges with `bash scripts/pm/os-regen-merge.sh` and recounts both header totals from the body. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2aa25ef commit 733822c

5 files changed

Lines changed: 223 additions & 12 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`DecisionConfigSchema` refuses `mode` on a `decision` that also declares a non-empty `conditions` list (#20168). `mode` belongs to the edge-branched decision alone: a `conditions` list is first-match on its own, so a `mode` beside one would be accepted and never read, and `mode: 'inclusive'` there would promise every matching branch while the run takes one.
6+
7+
Clause-②: no
8+
9+
This corrects a key that has not been released yet, so it narrows no published accept set. Timing, measured when this landed: the npm registry's `latest` `@objectstack/spec` is `17.4.0`, and its `json-schema/automation/DecisionConfig.json` declares `conditions` only, with `additionalProperties: false`. No `mode` was published. The Version Packages PR (`chore: version packages`) is open and unmerged. `mode` reaches its first release together with this refusal, so no ADR-0087 entry is owed.
10+
11+
- **The refusal**: one issue at `mode`, for either member. It reads "`mode: 'inclusive'` is not valid on a decision that declares a `conditions` list — `mode` belongs to the edge-branched decision alone." and names the two ways out:
12+
- delete `mode` and keep the list;
13+
- or move the branches onto the out-edges (a `condition` on each branch edge, `isDefault: true` on the fallback), delete `conditions`, and keep `mode`.
14+
- **Left alone**: `mode` on an empty `conditions` list, `mode` with `conditions` absent, and a `conditions` list with no `mode` all parse as before. A `mode` outside `'exclusive' | 'inclusive'` still gets its own value refusal first.
15+
- **Where it binds**: the doors that parse `DecisionConfigSchema`. Today that is a direct parse, including the `SCHEMALESS_NODE_CONFIG_SCHEMAS.decision` handle. `decision` config is still export-only, so a flow's registration and `os validate` do not run it yet. The published JSON Schema cannot state the rule, because no arm of the closed refinement projection fits it. `automation/DecisionConfig` therefore joins `dropped-refinements.baseline.json` and carries the site as `x-dropped-refinements`.

‎content/docs/references/automation/schemaless-node-config.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ const result = DecisionConditionSchema.parse(data);
137137
| Property | Type | Required | Description |
138138
| :--- | :--- | :--- | :--- |
139139
| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) |
140-
| **mode** | `Enum<'exclusive' \| 'inclusive'>` | optional | Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means); 'inclusive' = every one that holds. Declared ahead of the engine change that reads it: until that lands, an edge-branched decision takes every out-edge whose condition holds, whatever this says. A conditions list is first-match on its own. |
140+
| **mode** | `Enum<'exclusive' \| 'inclusive'>` | optional | Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means); 'inclusive' = every one that holds. Declared ahead of the engine change that reads it: until that lands, an edge-branched decision takes every out-edge whose condition holds, whatever this says. Refused beside a non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete conditions, and keep mode. |
141141

142142
### Nested Shape: `DecisionConfig.conditions[number]`
143143

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 211,
6-
"droppedRefinementSites": 604,
5+
"publishedSchemasWithDroppedRefinements": 212,
6+
"droppedRefinementSites": 605,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -413,6 +413,11 @@
413413
"fields.valueType"
414414
]
415415
},
416+
"automation/DecisionConfig": {
417+
"sites": [
418+
""
419+
]
420+
},
416421
"automation/EndConfig": {
417422
"sites": [
418423
""

‎packages/spec/src/automation/schemaless-node-config.test.ts‎

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
* guard. `decision` is the exception — it stays export-only (nothing parses it
1010
* at run time), so its pins below bind the authoring doors only: `tsc`, the
1111
* published JSON Schema and a direct parse. Its `mode` key is declared ahead of
12-
* the engine change that reads it (#15429).
12+
* the engine change that reads it (#15429), and is refused beside a non-empty
13+
* `conditions` list (ruling 5856786357 on #20168) — a refinement, so of those
14+
* doors it binds the direct parse and is declared dropped in the JSON Schema.
1315
*
1416
* The structural assertions at the bottom guard the downstream walkers that a
1517
* union-shaped contract would have broken, which is why #4343 converged the
@@ -25,6 +27,7 @@ import { objectStackErrorMap } from '../shared/error-map.zod.js';
2527
import {
2628
DecisionConditionSchema,
2729
DecisionConfigSchema,
30+
SCHEMALESS_NODE_CONFIG_SCHEMAS,
2831
ScriptConfigSchema,
2932
SubflowConfigSchema,
3033
getSchemalessNodeConfigJsonSchemas,
@@ -244,11 +247,8 @@ describe('DecisionConfigSchema.mode (#15429 item 2 — the contract half, declar
244247
expect('mode' in omitted, 'an omitted mode must not come back as a parsed default').toBe(false);
245248
expect(DecisionConfigSchema.parse({ mode: 'exclusive' })).toEqual({ mode: 'exclusive' });
246249
expect(DecisionConfigSchema.parse({ mode: 'inclusive' })).toEqual({ mode: 'inclusive' });
247-
// …and alongside a branch list, which the key does not forbid.
248-
expect(DecisionConfigSchema.parse({
249-
mode: 'exclusive',
250-
conditions: [{ label: 'big', expression: 'amount > 100000' }],
251-
})).toEqual({ mode: 'exclusive', conditions: [{ label: 'big', expression: 'amount > 100000' }] });
250+
// Beside a NON-EMPTY branch list the key is refused (ruling 5856786357 on
251+
// #20168) — pinned in the describe block below, with its controls.
252252
});
253253

254254
it('types the key as the closed pair at the tsc door', () => {
@@ -303,6 +303,132 @@ describe('DecisionConfigSchema.mode (#15429 item 2 — the contract half, declar
303303
});
304304
});
305305

306+
/**
307+
* Ruling 5856786357 on #20168 (letter A): `mode` belongs to the edge-branched
308+
* decision alone, so a decision declaring a NON-EMPTY `conditions` list AND
309+
* `mode` is refused at `mode`, with the ruled prescription — delete `mode`
310+
* (the list is first-match on its own), or move the branches onto the
311+
* out-edges, delete `conditions`, and keep `mode`.
312+
*
313+
* Key-vs-value note: the rule judges the KEY beside a non-empty list, whatever
314+
* member it names, so every refusal below is a full `safeParse` failure
315+
* located at `mode`, and every control a full `safeParse` success that
316+
* round-trips — never mere absence of one issue code.
317+
*/
318+
describe('DecisionConfigSchema — `mode` beside a non-empty `conditions` list is refused', () => {
319+
const BRANCH = { label: 'big', expression: 'amount > 100000' } as const;
320+
const BRANCHES = [BRANCH, { label: 'small', expression: 'amount <= 100000' }] as const;
321+
322+
/**
323+
* The ruled prescription on the message: the wording is the contract here
324+
* (the ruling names both ways out and #15429's acceptance list carries it),
325+
* so the first sentence is read verbatim and each remedy is required.
326+
*/
327+
function expectRuledRefusal(message: string, mode: 'exclusive' | 'inclusive'): void {
328+
expect(message.startsWith(
329+
`\`mode: '${mode}'\` is not valid on a decision that declares a \`conditions\` list — `
330+
+ '`mode` belongs to the edge-branched decision alone.',
331+
)).toBe(true);
332+
expect(message).toContain('A `conditions` list is first-match on its own');
333+
expect(message, 'remedy 1: drop mode').toContain('Either delete `mode` and keep the list');
334+
expect(message, 'remedy 2: branches onto the edges, keep mode')
335+
.toContain('move the branches onto the out-edges');
336+
expect(message).toContain('delete `conditions`, and keep `mode`');
337+
expect(message, 'a prescription an author is shown carries no tracker number').not.toMatch(/#\d{3,5}/);
338+
}
339+
340+
type Issue = { code: string; path: PropertyKey[]; message: string };
341+
const issuesOf = (result: { success: boolean; error?: { issues: ReadonlyArray<Issue> } }): ReadonlyArray<Issue> =>
342+
(result.success ? [] : result.error!.issues);
343+
344+
it.each(['inclusive', 'exclusive'] as const)(
345+
'refuses `mode: %j` beside a one-entry list — one custom issue at [mode], with the ruled prescription',
346+
(mode) => {
347+
const result = DecisionConfigSchema.safeParse({ conditions: [BRANCH], mode });
348+
expect(result.success).toBe(false);
349+
const issues = issuesOf(result);
350+
expect(issues).toHaveLength(1);
351+
expect(issues[0]!.code).toBe('custom');
352+
expect(issues[0]!.path).toEqual(['mode']);
353+
expectRuledRefusal(issues[0]!.message, mode);
354+
},
355+
);
356+
357+
it.each(['inclusive', 'exclusive'] as const)('refuses `mode: %j` beside a multi-entry list alike', (mode) => {
358+
const issues = issuesOf(DecisionConfigSchema.safeParse({ mode, conditions: BRANCHES }));
359+
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['mode']]]);
360+
expectRuledRefusal(issues[0]!.message, mode);
361+
});
362+
363+
it('is the same refusal through `SCHEMALESS_NODE_CONFIG_SCHEMAS.decision` — the handle a registration-time reader looks up by node type', () => {
364+
// #15429's registration reader and metadata-protocol's reference walk both
365+
// reach this contract by node type rather than by its export name, so the
366+
// pin is taken through that door too.
367+
const issues = issuesOf(SCHEMALESS_NODE_CONFIG_SCHEMAS.decision.safeParse({ conditions: [BRANCH], mode: 'inclusive' }));
368+
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['mode']]]);
369+
expectRuledRefusal(issues[0]!.message, 'inclusive');
370+
});
371+
372+
it('keeps its prescription under the ObjectStack error map a validator may pass per parse', () => {
373+
const result = DecisionConfigSchema.safeParse({ conditions: [BRANCH], mode: 'inclusive' }, { error: objectStackErrorMap });
374+
const issues = issuesOf(result);
375+
expect(issues).toHaveLength(1);
376+
expect(issues[0]!.path).toEqual(['mode']);
377+
expectRuledRefusal(issues[0]!.message, 'inclusive');
378+
});
379+
380+
it('a mode OUTSIDE the closed pair beside a list gets the value refusal first — one issue, never both', () => {
381+
// The enum refusal is a base-type issue, so the object's refinement does
382+
// not run over it: the author fixes the value, then meets this rule.
383+
const issues = issuesOf(DecisionConfigSchema.safeParse({ conditions: [BRANCH], mode: 'all' }));
384+
expect(issues).toHaveLength(1);
385+
expect(issues[0]!.code).toBe('invalid_value');
386+
expect(issues[0]!.path).toEqual(['mode']);
387+
expect(issues[0]!.message).toContain("`mode: 'all'` is not a decision mode");
388+
});
389+
390+
describe('CONTROLS — what the refusal must leave alone', () => {
391+
it.each(['inclusive', 'exclusive'] as const)('accepts `mode: %j` beside an EMPTY list, and it round-trips', (mode) => {
392+
const result = DecisionConfigSchema.safeParse({ conditions: [], mode });
393+
expect(result.success).toBe(true);
394+
expect(result.data).toEqual({ conditions: [], mode });
395+
expect(DecisionConfigSchema.parse(result.data)).toEqual(result.data);
396+
});
397+
398+
it.each(['inclusive', 'exclusive'] as const)('accepts `mode: %j` with `conditions` absent, and it round-trips', (mode) => {
399+
const result = DecisionConfigSchema.safeParse({ mode });
400+
expect(result.success).toBe(true);
401+
expect(result.data).toEqual({ mode });
402+
expect('conditions' in result.data!).toBe(false);
403+
expect(DecisionConfigSchema.parse(result.data)).toEqual(result.data);
404+
});
405+
406+
it('accepts a non-empty list with NO mode, and it round-trips', () => {
407+
for (const conditions of [[BRANCH], BRANCHES]) {
408+
const result = DecisionConfigSchema.safeParse({ conditions });
409+
expect(result.success, JSON.stringify(conditions)).toBe(true);
410+
expect(result.data).toEqual({ conditions });
411+
expect('mode' in result.data!).toBe(false);
412+
expect(DecisionConfigSchema.parse(result.data)).toEqual(result.data);
413+
}
414+
});
415+
416+
it('following either remedy parses', () => {
417+
const refused: Record<string, unknown> = { conditions: [...BRANCHES], mode: 'inclusive' };
418+
expect(DecisionConfigSchema.safeParse(refused).success).toBe(false);
419+
// Remedy 1 — delete `mode`, keep the list.
420+
const listOnly = { ...refused };
421+
delete listOnly.mode;
422+
expect(DecisionConfigSchema.parse(listOnly)).toEqual({ conditions: BRANCHES });
423+
// Remedy 2 — the branches move onto the out-edges (outside this config),
424+
// `conditions` is deleted, and `mode` stays.
425+
const modeOnly = { ...refused };
426+
delete modeOnly.conditions;
427+
expect(DecisionConfigSchema.parse(modeOnly)).toEqual({ mode: 'inclusive' });
428+
});
429+
});
430+
});
431+
306432
describe('structural contract — what the downstream walkers require', () => {
307433
it('keeps the tombstoned keys IN the shape, so the ratchet can see them retired', () => {
308434
// A `retiredKey()` is still a property. Deleting it outright would read as

0 commit comments

Comments
 (0)