Skip to content

feat(spec): DecisionConfigSchema declares an optional mode: 'exclusive' | 'inclusive' (contract half of the #15429 ruling) - #20162

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19867-decision-mode-inclusive
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19867-decision-mode-inclusive

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19867

Clause-②: yes

The contract half of the maintainer ruling on #15429 (record 5793803317, item 2), landed first by that ruling's own split order: spec key first, then the engine semantics and the os migrate meta conversion together in one PR, then the docs. This PR carries the spec key ONLY. The traversal change, the conversion, the lint hint, the status-quo pin rewrite and flows.mdx all stay with #15429 and are not touched here.

Ruling text, verbatim (item 2):

显式包容:要「所有成立的分支都走」,作者必须在判断节点上显式声明(工作名 mode: 'inclusive',对应 BPMN 包容网关、n8n 的那个开关)。这是 DecisionConfigSchema 上一个可选键 ⇒ packages/spec 契约变更,Clause-②: yes,实现 PR 走 needs:contract-review。

What changes

DecisionConfigSchema (packages/spec/src/automation/schemaless-node-config.zod.ts) gains one optional key:

mode: z.enum(['exclusive', 'inclusive'], {
  error: (issue) => (issue.code === 'invalid_value' ? decisionModePrescription(issue.input) : undefined),
}).optional()
  • Accepted: mode omitted, 'exclusive', 'inclusive', and either one next to a conditions list.
  • Refused: every other value, at path ['mode'], issue code invalid_value, with one prescription.
  • No .default('exclusive'), on purpose. A default would change the parsed output every consumer of this schema sees: parse({}) would start returning { mode: 'exclusive' }. "Omitted means exclusive" lives in the contract's prose and in the reader that will honour it. It is pinned: parse({}) returns {} with no mode key, and the published JSON Schema carries no default.
  • Types: DecisionConfig['mode'] and DecisionConfigParsed['mode'] are both 'exclusive' | 'inclusive' | undefined.

New .describe() text (quoted in full)

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.

New refusal prescription (quoted in full, rendered for input 'all')

mode: 'all' is not a decision mode. mode is the closed pair 'exclusive' | 'inclusive'. 'exclusive' declares that only the FIRST out-edge whose condition holds is taken, in the order the edges are declared (the BPMN exclusive gateway), and is what an omitted mode means. 'inclusive' declares that EVERY out-edge whose condition holds is taken (the BPMN inclusive gateway). Write one of the two, or omit the key for exclusive.

This is one message for every wrong value, not a did-you-mean. The likely wrong values ('all', 'first', 'parallel', true) are not typos an edit distance can reach. They are the same idea written the way another engine writes it, so the message explains what each legal value means. The message is a schema-level error, so it also wins over the objectStackErrorMap a validator may pass per parse. The ablation below shows that without it, that map's generic "Invalid value" text would take its place.

Docblocks, and why this wording (the measured choice the dispatch asked for)

The card and triage note 2 ask that the text not claim first-match before the engine does. I measured the window first:

  • logic-nodes.ts (the decision executor) reads config.conditions and nothing else.
  • The engine's traversal (engine.ts, the conditional-edge loop) reads no decision config key at all.
  • So at this head nothing reads mode. An edge-branched decision takes every out-edge whose condition holds, one after another, whatever mode says. decision-overlapping-edge-conditions.pin.test.ts pins exactly that as the status quo, and it stays green here (numbers below).

The wording therefore does three things:

  1. It states what each value declares. It does not say what the engine does.
  2. It says in the author-facing text itself that nothing reads the key yet, and what the engine does in the meantime. This is in the .describe(), the docblock and the changeset.
  3. It removes the existing false statement instead of repeating it. Before this PR, the module header and the DecisionConfigSchema docblock both called the edge-branched shape "a plain BPMN exclusive gateway". That is the A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 defect written as fact. Both sentences are rewritten. The module header's "conditions is its only key" becomes false with this PR, so that sentence is rewritten too.

On scope: mode speaks about the out-edges. The ruling addresses exactly that shape: its item 1 is about a decision with no config.conditions, and its conversion rewrites exactly those decisions. A conditions list is already first-match (logic-nodes.ts), and the ruling leaves it so. What mode: 'inclusive' should do next to a conditions list is NOT decided here. It is an open question for #15429 (see Acceptance notes). This PR neither forbids the combination nor gives it a meaning.

The docblock paragraph that starts "Declared ahead of its enforcement" says it is rewritten together with #15429's engine change. That is where the ruling's item 5 puts the rewrite.

Tests (pinned at 5fbb7ed8)

New describe('DecisionConfigSchema.mode …') in packages/spec/src/automation/schemaless-node-config.test.ts:

  • Accepts: {} is returned as {} with no mode injected; { mode: 'exclusive' }; { mode: 'inclusive' }; mode next to conditions.
  • Refuses: 'all', 'first', 'parallel', 'Inclusive', true and null. Each case checks all three parts of the refusal: exactly one issue, code === 'invalid_value', path equal to ['mode'], and the prescription (the echoed input, the closed pair, and what an omitted key means). Each also checks the message carries no tracker number.
  • Error-map precedence: the prescription survives safeParse(…, { error: objectStackErrorMap }).
  • tsc door: expectTypeOf pins the closed pair on both DecisionConfig and DecisionConfigParsed.
  • JSON Schema: mode is enum: ['exclusive', 'inclusive'] with no default, not required, and there is no top-level anyOf/oneOf/allOf.
  • Key-set pin updated on purpose: ['conditions'] becomes ['conditions', 'mode']. The pin's comment now names the cross-repo consequence (see Acceptance notes).

Runs (all through os-verify-lock; exit codes captured before any pipe):

What Result
@objectstack/spec full suite (vitest run --project local) 540 files / 15,807 passed, 2 todo
@objectstack/spec typecheck (tsc, scripts program, test program) exit 0. The test file is in tsconfig.test.json's program (--listFilesOnly: 1 hit among 511 test files). check:test-typecheck holds this file at its 1 pre-existing pinned signature, so the new expectTypeOf lines add no errors
Consumer: @objectstack/service-automation, full 145 files / 1,729 passed. It includes config-expression-ledger.test.ts (the only test there reading getSchemalessNodeConfigJsonSchemas()), decision-overlapping-edge-conditions.pin.test.ts (the status-quo pin: still green, so no behaviour moved), decision-branch-routing.test.ts, logic-nodes.test.ts and config-schemas.test.ts
Consumer: @objectstack/metadata-protocol, full 188 files passed, 3 skipped (env-gated) / 2,686 tests passed, 19 skipped. It includes reference-sites.derivation.test.ts: reference-sites.ts walks SCHEMALESS_NODE_CONFIG_SCHEMAS, and an enum is not a name-shaped site
@objectstack/lint not a consumer: nothing in packages/lint imports DecisionConfigSchema or SCHEMALESS_NODE_CONFIG_SCHEMAS (repo-wide git grep of both names)

Ablation (one-time proof, no permanent file). Done after the commit, through scripts/ablation-replace.mjs. The mutation replaced the schema-level error with error: () => undefined. Anchor hits went 1 → 0 and the blob changed 67cb1f11 → 02b7fc37. The file then read 7 failed / 30 passed: the six refusal cases plus the error-map case. After the restore, git hash-object equals the HEAD blob 67cb1f11, and git diff HEAD is empty.

Reverse verification against the REBUILT dist/automation/index.d.ts. A scratch program outside the repo assigned { mode: 'all' } to DecisionConfig. tsc exited 2 with TS2322: Type '"all"' is not assignable to type '"inclusive" | "exclusive" | undefined'. The control leg, with only { mode: 'inclusive' }, exited 0. From service-automation's own resolution of @objectstack/spec/automation (the rebuilt dist), { mode: 'all' } is refused with invalid_value at ['mode'], and { mode: 'inclusive' } is accepted.

Generated artifacts

Regenerated, not hand-edited:

  • packages/spec/authorable-surface/automation.json: +automation/DecisionConfig:mode (from the build).
  • content/docs/references/automation/schemaless-node-config.mdx: from gen:docs, after check:generated named it the one stale artifact.

authorable-surface.base.json is untouched. check:generated then passes all 15 artifacts, including check:liveness and check:authorable-surface.

Gates

Derived from the actual change set with dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 5fbb7ed8: 107 commands, every one run with its exit code written to disk. --ran reconciliation: 107 derived, 105 run green, 2 NOT MEASURED, 0 UNRUN.

  • NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE (exit 3). It needs a dist for every workspace package, which means a whole-workspace build. In its place I loaded every CJS require entry of @objectstack/spec itself: 18 loaded, 0 failed.
  • NOT MEASURED: check:type-check-debt, reason: PREREQUISITE (exit 3). It needs the whole-workspace .d.ts closure (lint.yml builds ./packages/* first). This PR touches no DEBT-ledgered package's TypeScript.
  • Also run: the roster gates whose rosters sit under my paths (check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:error-code-provenance), all exit 0.
  • The level axis of check-changeset-no-major was driven offline with --event on a pull_request payload carrying THIS body. It exited 0 and printed: LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/**/src/** it moves is graded patch (the changeset grades @objectstack/spec minor).

Acceptance notes

  • objectui, measured read-only at the .objectui-sha pin f8a9d0fb. flow-node-config.spec-reconciliation.test.ts reconciles its hand-written decision form against DecisionConfigSchema.shape in both directions. At the pin, the decision form offers only conditions (plus the legacy-gated condition). So once objectui installs a published spec that carries mode, its "read by the executor but not offered by the designer form" assertion reds for decision. It stays red until the form offers mode.
  • Open question for A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429, not decided here: what mode: 'inclusive' means on a decision that ALSO declares conditions. That shape already takes one branch by label, and the ruling's item 1 and its conversion address only the edge-branched shape. The options are refusing the combination (at os validate / registration) or giving it a meaning. Refusing is the tighter contract.
  • Where the closed pair binds today: decision config is export-only (nothing parses it at run time), so the pair is enforced by tsc, the published JSON Schema and a direct parse. Those are the same doors conditions has. A stored flow carrying mode: 'bogus' is not refused at run time by this PR. That parse belongs with the engine change that reads the key.
  • Main moved by 2 commits since the branch point (9401b842, 49144fcc, in packages/rest and packages/core only). There is no overlap with this diff, and the merge queue rebuilds on the current main.

Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_value (literal, a string literal in DecisionConfigSchema))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/automation.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 98 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 49144fccc873c48babf3c45e56982b6c18169487 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d3107863a42afac68eed5ef8153ee4301914c189 — the merge of head 5fbb7ed86a07287014af17e6a11749a720a7e20f into base 49144fccc873c48babf3c45e56982b6c18169487, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d3107863a42afac68eed5ef8153ee4301914c189 && git checkout d3107863a42afac68eed5ef8153ee4301914c189
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 49144fccc873c48babf3c45e56982b6c18169487 5fbb7ed86a07287014af17e6a11749a720a7e20f && git checkout -B drift-repro 49144fccc873c48babf3c45e56982b6c18169487 && git merge --no-ff 5fbb7ed86a07287014af17e6a11749a720a7e20f

node scripts/docs-audit/affected-docs.mjs --json 49144fccc873c48babf3c45e56982b6c18169487

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 49144fccc873c48babf3c45e56982b6c18169487 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5fbb7ed86a07287014af17e6a11749a720a7e20f

① Derived judgments

Inputs read: card #19867 (body + 3 comments), ruling 5793803317 (#15429), PR #20162 body/diff/files, the head's check-runs, origin/main (2bbebf5) and the head. Probe: the head checked out into a throwaway worktree under the reviewer's scratch dir (removed after); vitest run --project local src/automation/schemaless-node-config.test.ts = 1 file, 37/37 passed, exit 0; plus a direct tsx probe of the schema (zod 4.6.1). Line numbers below are at the head sha.

  • Accept set widens by exactly one optional key — packages/spec/src/automation/schemaless-node-config.zod.ts:482-491: mode: z.enum(['exclusive','inclusive'], { error }).optional().describe(…), no .default(). RIGHT against ruling item 2 (an OPTIONAL key on DecisionConfigSchema, working name mode, "take every branch" must be written down) and the card's Is-bullet (values 'exclusive' (the default) and 'inclusive'). Probe: parse({}) → {} with 'mode' in out === false; parse({mode:'exclusive'}) → {mode:'exclusive'}; parse({mode:'inclusive'}) → {mode:'inclusive'}; parse({mode:undefined}) → {}; mode beside a conditions list parses; a conditions-only config parses unchanged. Nothing is injected into parsed output.
  • Refuse set — every non-member ('all', 'first', 'first_match', 'parallel', 'Inclusive', 'EXCLUSIVE', ' exclusive', true, null, 1, {}, []) is refused with exactly one issue, code: invalid_value, path: ['mode'], and the one prescription from decisionModePrescription (:200-207): it echoes the value, names the closed pair, says what each member DECLARES and what an omitted key means, carries no tracker number. RIGHT (card execution note 3). The schema-level error outranks a per-parse objectStackErrorMap (probe confirms the prescription survives safeParse(…, { error: objectStackErrorMap })), so a validator passing the repo map cannot regress it to zod's generic text.
  • Nothing previously accepted is now refused — unknown keys were already refused by strictObject (probe: { modes: 'inclusive' } → unrecognized_keys, now with a did-you-mean onto mode); {} and conditions-only parse as before; the condition guidance entry (:180-188) and the retiredKey tombstones are untouched. The key-set pin moves ['conditions'] → ['conditions','mode'] deliberately (schemaless-node-config.test.ts:339-347), which is a widening, not drift. RIGHT.
  • JSON Schema — getSchemalessNodeConfigJsonSchemas().decision (:572-589): properties.mode = { type: 'string', enum: ['exclusive','inclusive'], description: … }, no default, no required array at all, additionalProperties: false, no top-level anyOf/oneOf/allOf. The raw z.toJSONSchema(schema, { unrepresentable:'any', io:'input' }) path that packages/metadata-protocol/src/reference-sites.ts:480-483 takes yields the same node; an enum-constrained string is not name-shaped there, so no reference site is derived from mode. RIGHT; the describe text rides into the published description.
  • Public surface — no new export; DecisionConfig / DecisionConfigParsed (:494-495, both already listed in packages/spec/api-surface/automation.json:85-86) widen by the same optional member ('exclusive' | 'inclusive' | undefined, pinned by expectTypeOf at test :253-256). Authorable surface: +automation/DecisionConfig:mode at packages/spec/authorable-surface/automation.json:92, regenerated; authorable-surface.base.json correctly untouched (the base is the deletion anchor; an addition is auto-recorded). Generated reference page content/docs/references/automation/schemaless-node-config.mdx (AUTO-GENERATED header) regenerated, table row at :140. Build Docs check-run: success. RIGHT.
  • Published text is TRUE today — .describe() (:485-491), the schema docblock (:432-461), the module header (:67-73), the reference page (:66-73, :140) and the changeset each say in words that nothing reads mode yet and that an edge-branched decision takes EVERY out-edge whose condition holds. Verified at the head: packages/services/service-automation/src/builtin/logic-nodes.ts:47-49 reads config.conditions and nothing else; engine.ts:10200-10210 (traverseNext) executes every conditional out-edge whose condition holds, one after another, and reads no decision config key; builtin/decision-overlapping-edge-conditions.pin.test.ts pins exactly that as the status quo and is untouched. Every "first"/"exclusive" statement is phrased as what the VALUE declares and is followed by the status-quo sentence; the two pre-existing "a plain BPMN exclusive gateway" sentences (the A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 defect written as fact) were removed rather than repeated. Card caution and triage note 2 honoured. RIGHT.
  • Scope — 5 files, all inside the claim's file surface; no packages/services/**, no os migrate meta conversion, no lint hint, no pin-test rewrite, no content/docs/automation/flows.mdx. The ruling's split order (spec key first, engine + migration together next, docs last) is honoured; nothing of A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 rode along. RIGHT.
  • Nit (non-blocking) — decisionModePrescription (:201) builds the echo with JSON.stringify(input); a BigInt input makes safeParse THROW (Do not know how to serialize a BigInt, probe) instead of returning success: false. Unreachable from JSON/YAML metadata and refused by tsc, so no authoring door reaches it; a one-line guard (typeof input === 'bigint') closes it.

② Semver level

  • Changeset .changeset/19867-decision-config-mode.md: "@objectstack/spec": minor, Clause-②: yes (widening); PR body Clause-②: yes. Both read yes under scripts/pm/clause2-line.mjs (yes / yes (widening) are one reading, "both take at least minor").
  • minor is RIGHT under the repo's rules: .github/workflows/pr-automation.yml:753-760 (WHICH LEVEL — "a new accepted key or value takes at least minor"), and the level axis in scripts/check-changeset-no-major.mjs (a PR declaring clause ② must grade at least one package whose packages/**/src/** it moves minor or above; @objectstack/spec is the only such package here). Check Changeset check-run: success. No major (launch-window guard), correctly.
  • ADR-0087: no BREAKING banner and no (narrowing) arm, so scripts/check-adr-0087-registration.mjs owes no disposition marker; a registration is correctly ABSENT — nothing is retired, renamed or converted here. The os migrate meta conversion that writes mode: 'inclusive' onto existing edge-branched decisions is ruling item 3 and lands with the engine change in A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429, where it will owe its own ADR-0087 entry.
  • needs:contract-review label absent on the PR is correct: the label was retired by ruling 5770886272 (Retire the in-seat clause-② contract review — the rule, its gate label, and the 9k-line checker's gate role #19061) per the clause2-line.mjs header; this record is the carrier the ruling kept.

③ Boundary flags

  1. objectui reconciliation reds on the next spec bump (dev's finding) — CONFIRMED, and sharper than stated; ESCALATE as a cross-repo sequencing item. At the pin f8a9d0fb (.objectui-sha), packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts:75-83 zodKeys() counts every .shape key whose description does not start with [REMOVED], and :142-150 asserts contract-minus-offered is empty; the decision form (flow-node-config.ts:577-605) offers only conditions (plus the legacy-gated condition). So the first objectui install of a spec carrying mode fails decision with "read by the executor but not offered by the designer form" — a message that is itself false for this key. objectui has no declared-ahead exemption: renderOnly requires a legacy-gated field (:159-166). Either objectui's spec bump waits for A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429's engine change plus a form that offers mode, or objectui gets a pending-key exemption first. Not this PR's CI (Console Pin Gate: skipped on this head), but the seat/A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 carrier must own the ordering.
  2. mode beside a conditions list (dev's open question) — answered for this PR, with a consequence the PR does not name; ESCALATE to A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429. The combination is ACCEPTED (probe) and given no meaning, and the text says mode speaks about the out-edges while a conditions list is first-match on its own (true today, logic-nodes.ts:51). Because it is accepted at every published door now (tsc, JSON Schema, direct parse), choosing to REFUSE it later (the PR's recommended option A) becomes a narrowing of a published accept set — Clause-②: no (narrowing) with an ADR-0087 disposition — not a free choice. A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 should decide before a release ships this key, or take option B/C.
  3. Declared ≠ enforced, and no instrument sees it (the author should have flagged this). mode is outside the liveness ledger by construction: packages/spec/liveness/flow.json props.nodes.children.config is one blanket live row citing engine.ts, and FlowNodeSchema.config is z.record(z.string(), z.unknown()) (flow.zod.ts:488), so no walker reaches DecisionConfigSchema and no row is owed or possible (Spec property liveness: success, trivially). The repo's one machine-readable spelling of "declared, intentionally not enforced yet" — ADR-0049's [EXPERIMENTAL — not enforced] describe prefix (docs/adr/0049-no-unenforced-security-properties.md:82-88, read by the liveness classifier) — is not used; the not-read status is prose only. Nothing mechanical goes red when A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 lands the reader and forgets to rewrite :67-73, :451-461 and :485-491; the only carrier is the docblock's own "this paragraph is rewritten with them". Non-blocking (mode is routing, not a security boundary, and the ruling ordered the split), but: use the ADR-0049 prefix now, or name those three sites plus flows.mdx:1349 ("BPMN exclusive gateway — the default choice", still false, ruling item 5) in A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429's acceptance list so the published text does not go false in the opposite direction.
  4. Consumers that iterate the schema's keys — checked, none broken. metadata-protocol/src/reference-sites.ts:480 (enum → no site); service-automation/src/builtin/config-expression-ledger.test.ts (walks xExpression markers; mode carries none); the authorable-surface ratchet (addition auto-recorded); objectui zodKeys (item 1). git grep finds no hard-coded decision key list in packages/lint/src, packages/cli/src or the engine. validateNodeConfigKeys exempts schemaless types (engine.ts:9208, :9515) and nothing parses decision config at run time, so a stored mode: 'bogus' is not refused at registration or execution — the PR states this; the run-time parse belongs with the reader (A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429).
  5. Check-runs on the head at record time (37 runs): every completed run is success or skipped — Check Changeset, Spec property liveness, Lint & Repo Gates, Type Check · source / consumer / debt ledger, Build Core, Build Docs, Dogfood Regression Gate 1-3 + Verify CLI, Temporal Conformance, Governed Surface Queue Guard, the claim / single-writer / part-of guards, Test Core 3/6, 4/6, 6/6. Still in progress when this record was written: Type Check · workspace, Test Core 1/6 and 5/6. The seat should confirm those three before enqueue; nothing in the diff (spec schema + its test + regenerated artefacts + changeset) gives a reason to expect a red.

Implemented-by: claude/issue-19867-decision-mode-inclusive
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants