Repository navigation
feat(spec): DecisionConfigSchema declares an optional mode: 'exclusive' | 'inclusive' (contract half of the #15429 ruling) - #20162
Conversation
…inclusive') Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…angeset Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: ① Derived judgmentsInputs read: card #19867 (body + 3 comments), ruling 5793803317 (#15429), PR #20162 body/diff/files, the head's check-runs,
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
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 metaconversion 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 andflows.mdxall stay with #15429 and are not touched here.Ruling text, verbatim (item 2):
What changes
DecisionConfigSchema(packages/spec/src/automation/schemaless-node-config.zod.ts) gains one optional key:modeomitted,'exclusive','inclusive', and either one next to aconditionslist.['mode'], issue codeinvalid_value, with one prescription..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 nomodekey, and the published JSON Schema carries nodefault.DecisionConfig['mode']andDecisionConfigParsed['mode']are both'exclusive' | 'inclusive' | undefined.New
.describe()text (quoted in full)New refusal prescription (quoted in full, rendered for input
'all')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-levelerror, so it also wins over theobjectStackErrorMapa 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) readsconfig.conditionsand nothing else.engine.ts, the conditional-edge loop) reads no decision config key at all.mode. An edge-branched decision takes every out-edge whose condition holds, one after another, whatevermodesays.decision-overlapping-edge-conditions.pin.test.tspins exactly that as the status quo, and it stays green here (numbers below).The wording therefore does three things:
.describe(), the docblock and the changeset.DecisionConfigSchemadocblock both called the edge-branched shape "a plain BPMN exclusive gateway". That is the A decision node with no declaredconfig.conditionstakes 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 "conditionsis its only key" becomes false with this PR, so that sentence is rewritten too.On scope:
modespeaks about the out-edges. The ruling addresses exactly that shape: its item 1 is about a decision with noconfig.conditions, and its conversion rewrites exactly those decisions. Aconditionslist is already first-match (logic-nodes.ts), and the ruling leaves it so. Whatmode: 'inclusive'should do next to aconditionslist 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 …')inpackages/spec/src/automation/schemaless-node-config.test.ts:{}is returned as{}with nomodeinjected;{ mode: 'exclusive' };{ mode: 'inclusive' };modenext toconditions.'all','first','parallel','Inclusive',trueandnull. Each case checks all three parts of the refusal: exactly one issue,code === 'invalid_value',pathequal 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.safeParse(…, { error: objectStackErrorMap }).expectTypeOfpins the closed pair on bothDecisionConfigandDecisionConfigParsed.modeisenum: ['exclusive', 'inclusive']with nodefault, notrequired, and there is no top-levelanyOf/oneOf/allOf.['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):@objectstack/specfull suite (vitest run --project local)@objectstack/spectypecheck(tsc, scripts program, test program)tsconfig.test.json's program (--listFilesOnly: 1 hit among 511 test files).check:test-typecheckholds this file at its 1 pre-existing pinned signature, so the newexpectTypeOflines add no errors@objectstack/service-automation, fullconfig-expression-ledger.test.ts(the only test there readinggetSchemalessNodeConfigJsonSchemas()),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.tsandconfig-schemas.test.ts@objectstack/metadata-protocol, fullreference-sites.derivation.test.ts:reference-sites.tswalksSCHEMALESS_NODE_CONFIG_SCHEMAS, and an enum is not a name-shaped site@objectstack/lintpackages/lintimportsDecisionConfigSchemaorSCHEMALESS_NODE_CONFIG_SCHEMAS(repo-widegit grepof both names)Ablation (one-time proof, no permanent file). Done after the commit, through
scripts/ablation-replace.mjs. The mutation replaced the schema-levelerrorwitherror: () => undefined. Anchor hits went 1 → 0 and the blob changed67cb1f11→02b7fc37. The file then read 7 failed / 30 passed: the six refusal cases plus the error-map case. After the restore,git hash-objectequals the HEAD blob67cb1f11, andgit diff HEADis empty.Reverse verification against the REBUILT
dist/automation/index.d.ts. A scratch program outside the repo assigned{ mode: 'all' }toDecisionConfig. tsc exited 2 withTS2322: Type '"all"' is not assignable to type '"inclusive" | "exclusive" | undefined'. The control leg, with only{ mode: 'inclusive' }, exited 0. Fromservice-automation's own resolution of@objectstack/spec/automation(the rebuilt dist),{ mode: 'all' }is refused withinvalid_valueat['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: fromgen:docs, aftercheck:generatednamed it the one stale artifact.authorable-surface.base.jsonis untouched.check:generatedthen passes all 15 artifacts, includingcheck:livenessandcheck:authorable-surface.Gates
Derived from the actual change set with
dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat5fbb7ed8: 107 commands, every one run with its exit code written to disk.--ranreconciliation: 107 derived, 105 run green, 2 NOT MEASURED, 0 UNRUN.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 CJSrequireentry of@objectstack/specitself: 18 loaded, 0 failed.check:type-check-debt, reason: PREREQUISITE (exit 3). It needs the whole-workspace.d.tsclosure (lint.yml builds./packages/*first). This PR touches no DEBT-ledgered package's TypeScript.check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:error-code-provenance), all exit 0.check-changeset-no-majorwas driven offline with--eventon apull_requestpayload 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/specminor).Acceptance notes
.objectui-shapinf8a9d0fb.flow-node-config.spec-reconciliation.test.tsreconciles its hand-written decision form againstDecisionConfigSchema.shapein both directions. At the pin, the decision form offers onlyconditions(plus the legacy-gatedcondition). So once objectui installs a published spec that carriesmode, its "read by the executor but not offered by the designer form" assertion reds fordecision. It stays red until the form offersmode.config.conditionstakes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429's engine change, so the designer does not offer a switch the engine ignores.config.conditionstakes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429, not decided here: whatmode: 'inclusive'means on a decision that ALSO declaresconditions. 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 (atos validate/ registration) or giving it a meaning. Refusing is the tighter contract.tsc, the published JSON Schema and a direct parse. Those are the same doorsconditionshas. A stored flow carryingmode: 'bogus'is not refused at run time by this PR. That parse belongs with the engine change that reads the key.9401b842,49144fcc, inpackages/restandpackages/coreonly). There is no overlap with this diff, and the merge queue rebuilds on the current main.Generated by Claude Code