feat(automation)!: edge-branched decision is exclusive; mode: 'inclusive' takes every branch (#15429) - #20344
objectstack-fleet[bot] wants to merge 7 commits into
Conversation
…ive'` takes every branch A `decision` with no `config.conditions` now takes the FIRST conditioned out-edge whose condition holds, in declaration order (BPMN exclusive gateway); the passed-over siblings record a `skipped` step. `mode: 'inclusive'` takes every one, sequentially. `isDefault` is unchanged. - registration parses `DecisionConfigSchema` and refuses an invalid `mode` (value outside the pair, or beside a non-empty `conditions` list) with the schema's sentence; `os validate` reports the same as `flow-decision-mode-invalid`, plus the advisory `flow-decision-inclusive-overlap`. - ADR-0087 D2 `flow-decision-mode-inclusive-explicit` (retired from the load path, refused by the flow rehydration seam by id) writes `mode: 'inclusive'` onto decisions with >= 2 conditioned out-edges for `os migrate meta --from 17`; D3 entry `flow-decision-edge-branching-first-match` carries the judgment. - the status-quo pin is rewritten as the contract pin; docs describe both modes. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…nswer Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Both registries gained an entry on each side on the same lines (`view-overlay-owner-hidden-removed` from #20286, this branch's `flow-decision-mode-inclusive-explicit`); both kept, landing order. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…anchor the retirement-jurisdiction citation to the docblock that decided it - packages/lint/src/index.ts: FLOW_DECISION_MODE_INVALID and FLOW_DECISION_INCLUSIVE_OVERLAP join the flow-pattern export block (rule-id-barrel-exports pin). - conversions/registry.ts: the retiredFromLoadPath jurisdiction is cited from MetadataConversion's docblock and ADR-0087's 2026-07-31 addendum, not from a tracker number that no longer resolves. - schemaless-node-config.zod.ts: the mode JSDoc no longer spells an omitted-means-every sentence the empty-state gate has to classify. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 a52e3bec51e296bcfd0d49cba8620e4e38f773bf && git checkout a52e3bec51e296bcfd0d49cba8620e4e38f773bf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8af914a30d1dab62fd7b73d1d847076b639cb2ab 6bc84ba59199744b671cc6e48e584308fdbdee21 && git checkout -B drift-repro 8af914a30d1dab62fd7b73d1d847076b639cb2ab && git merge --no-ff 6bc84ba59199744b671cc6e48e584308fdbdee21
node scripts/docs-audit/affected-docs.mjs --json 8af914a30d1dab62fd7b73d1d847076b639cb2ab
|
…lusive-explicit` by id (#15429 patch round) The entry is a DEFAULT FLIP: an omitted `mode` IS the exclusive gateway by the contract on `DecisionConfigSchema`, so writing `mode: 'inclusive'` is a reinterpretation that is sound only where the source's age is a fact — `os migrate meta --from 17`. The artifact-ingestion door's trigger is the declared `engines.protocol` floor, and `^17.0.0` is what `create-objectstack` stamps, so an app scaffolded today against the exclusive contract lands inside the window and would be handed an inclusive gateway it never asked for. The id joins `DEFAULT_FLIPS_NOT_REPLAYED_HERE` beside the `app-hidden-to-unpublished` precedent, with its reason; the door pin has four legs (subject, strict parse, negative, firing control through the primitive). The engine seam's `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` docblock and the conversion entry's docblock now cite the door precisely (「must」 became 「does」). Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
… sentence the repo pin requires (#15429 CI fix) CI `Test Core (1/6)` ran `packages/spec` `test:repo` and the repo-project pin `src/shared/retired-key-migrate-sentence.test.ts` refused two sites in `migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts`: the `reason` prose quoted the command mid-sentence ("the diff `os migrate meta --from 17` prints is where…") and `acceptanceCriteria` opened with a bespoke "Run `os migrate meta --from 17` over each authored stack…" — neither is the house sentence the pin requires as the LAST sentence of any literal that names the command, and the pin's anti-vacuity case turned red with it. - `reason` no longer names the command (the chain replay's edit list is where the judgment is made); `acceptanceCriteria` now ends with the house sentence "Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand." and opens with the review list instead. - `migrations/registry.ts` regenerated from the entry (`gen:migration-registry`; 298 semantic, 217 retired-key, 199 retired-def — counts unchanged). - `content/docs/automation/flows.mdx` upgrade callout reworded to the same house sentence so the docs and the entry read identically. Stored-row sentences in the entry are untouched. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15429
Clause-②: yes
Carries the maintainer's ruling
5793803317(「跟主流对齐」, 2026-09-23) as ONE change, routed todomain:specby5860007474and dispatched by thedomain:specseat 4 PM (session_01CiCTczDo7tGhafXjf61dUJ, claim5860277199). Branch base10ea9eb2e;origin/main(a78f731ad) merged throughos-regen-merge.sh; every reading below is at heade92edee5bunless it says otherwise; the patch commit27a1a4598(the seat's answer A in5861311629) carries its own readings where stated. (Body revised by the seat at 2026-09-28T01:34Z from the patch-round report5861745226.)Coordination card: objectstack-ai/objectui#10750 (the designer offers
mode). Nothing is written in objectui here.What changes
AutomationEngine.traverseNext: on adecisionnode the conditioned out-edges are evaluated in the order the flow'sedgesarray declares them and the FIRST one whose condition holds is the branch; its later siblings are not evaluated and record the sameskippedstep a closed gate does (skippedBynames the gate and the edge).isDefaultis unchanged: it runs when no conditioned sibling did. Scoped todecision: conditioned out-edges of any other node type keep the every-true-edge traversal (the census below found none).config.mode: 'inclusive'takes every out-edge whose condition holds, one successor at a time (neverPromise.all; the pin's positive control proves the instrument can see interleaving).5857171841).registerFlowparses every decision's config through the spec'sDecisionConfigSchemaand refuses the flow on any issue rooted atmode— the refinement (modebeside a non-emptyconditionslist, either member) and the value refusal — with the schema's own sentence atnode 'x' (decision) at config.mode, inside ADR-0031 regions too. Judged onmodealone, deliberately: the same parse also refuses an undeclared key, but that strictness binds at authoring by the standing decision in the module header ofschemaless-node-config.zod.ts, and refusing an inert extra key at boot would be a second behaviour change riding a ruling that ordered one. Measured reach of the alternative: zero decision nodes in either corpus carry a key other thanconditions, so promoting it later is cheap.os validatedoor.@objectstack/lintgainsflow-decision-mode-invalid(gating; the finding IS the schema's issue message, so both doors say one sentence) andflow-decision-inclusive-overlap(advisory, ruling item 4:mode: 'inclusive'with two or more conditioned out-edges; besideflow-decision-unconditional-branch, which is about an out-edge nothing gates).flow-decision-mode-inclusive-explicit(protocol 18): a decision with noconditionslist, nomode, and two or more conditioned out-edges (afaultedge is error routing; a blank condition is none; regions walked through the shared slot table) getsmode: 'inclusive'written, with a notice naming the count. One conditioned edge plus a default is left alone; an authoredmodeis left alone (idempotent by construction). No inference over the conditions, per the ruling. Paired D3 entryflow-decision-edge-branching-first-matchnames the D2 id as a whole word and carries the three-way judgment the diff asks for.MIGRATIONS_BY_MAJOR[18]wires the id and its rationale grows a sentence.decision-overlapping-edge-conditions.pin.test.tsis the contract pin now, per its own header.flows.mdx, theDecisionConfigSchemadocblock andmodedescribe, the module header (the declared-ahead sites of5852576993item 2),logic-nodes.ts, andengine.ts's traversal comment all describe both modes; the reference mdx is regenerated.PM mechanism assumptions, measured
1. Where the traversal lives — held. Both sites relocated by content: the conditional loop under the old 「evaluate sequentially (mutually exclusive)」 comment in
engine.ts(traverseNext), and theconfig.conditionsfirst-match inbuiltin/logic-nodes.ts(untouched, still label-narrowing). Declaration order isflow.edgesarray order:traverseNextfilters that array in place;FlowSchema.parse(region transform included) and every conversion walker are copy-on-write maps that never reorder;normalizeStackInputnormalizes map-form collections at the stack level and never touches a flow'sedges;canonicalizeStoredFlowruns those same two. Grep for any edge re-sort acrosspackages/*/srcandpackages/*/*/src(edges.sort,sortEdges,.edges.slice().sort,localeCompareon edges): 0 hits. NOT MEASURED: the Studio designer's own serialization order at save time — objectui is not checked out in this container; server side,saveMetaItemcanonicalizes without reordering.2. The migration predicate — census. Corpus: this repository's examples at
10ea9eb2eandobjectstack-ai/hotcrmat2f7b2326(read-only), every flow module loaded and every graph walked including regions; platform packages ship no decision node (grep overpackages/platform-objects,plugins,services: only the executor and the README).mode)crm_convert_lead_wizard.check_converted: 1 conditioned +isDefault)lead_conversion.decision_duplicate, the #1555 node, now three conditioned edges)Every one of the 17 positives is a hand-written partition by inspection (a predicate beside its negation,
>beside<=,has()beside!has(), hotcrm'sCASE_HAS_OWNERbeside its exact complement,memberSource != "contacts"beside== "contacts"), so first-match changes none of their runs; the conversion still writes the key onto all 17, as ruled, and the D3 entry tells the author to delete it there. No decision in either corpus carries a config key other thanconditions.examples/**is outside this claim's surface and is not edited: the four in-tree positives partition, so nothing changes at boot.3. Stored flows — FAILED, and adapted. The assumption was that the conversion replays at rehydration like the other step-18 entries. It cannot: this is a DEFAULT FLIP (the old shape still parses and now means exclusive), and the flow rehydration seam serves post-flip authored bodies too —
canonicalizeStoredFlowis reached by the boot pull for code-shipped flows, byPOST /automation, bysaveMetaItem(every Studio save) and byduplicatePackage, all through one two-argument signature, none dated. Replaying there would rewrite every NEW exclusive decision into an inclusive one at registration and persist it at save, and the ruled default would be unobservable. The registry's own doctrine for this class (excludeConversionIds, the artifact door'sDEFAULT_FLIPS_NOT_REPLAYED_HEREforapp-hidden-to-unpublishedon #17885, the WITHDRAWNfield-required-notnull-explicitnote) says a seam that cannot state 「this body predates the flip」 refuses the entry by id. So:retiredFromLoadPath: true(no authoring window — 「不留过渡窗口」) and replays where the operator asserts the source's age:os migrate meta --from 17(the D3 chain), pinned both ways;canonicalizeStoredFlowrefuses it by id (CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, reason at the call site), pinned onparsed,storableand notices, with the chain as the firing control;sys_metadataflows are therefore rewritten by nothing today:os migrate meta --storedcanonicalizes through that same seam. A decision saved from the Studio before this release with two or more conditioned out-edges and nomodenow runs first-match. The D3 entry states this as the judgment owed (list those rows, declaremodein the designer); an operator-asserted opt-in on the stored pass is the follow-up plumbing (migrateStoredMetadatainmetadata-protocol→ the engine seam), outside this claim's surface.27a1a4598, the seat's answer A in5861311629).packages/metadata-core/src/artifact-forward-conversion.tslistsflow-decision-mode-inclusive-explicitinDEFAULT_FLIPS_NOT_REPLAYED_HEREbeside theapp-hidden-to-unpublishedprecedent, with its reason: the door's trigger is the artifact's declaredengines.protocolfloor,^17.0.0is whatcreate-objectstackstamps, so an app scaffolded today against the exclusive contract lands inside the window and would otherwise be handed an inclusive gateway it never asked for. The door pin has four legs (subject, the strict parse the door feeds, negative, firing control), and the engine seam's and the entry's docblocks now cite the door precisely.4. Serial state — moved, merged.
origin/maingained #20286 (view-overlay-owner-hidden-removed) on the same registry lines;os-regen-merge.shmerged it (both entries kept in landing order inconversions/registry.tsand inMIGRATIONS_BY_MAJOR[18], rationale concatenated),gen:migration-registryregenerated to an identical file,check:generatedfound every artifact current, and every sibling symbol was asserted present on both sides by exact-name grep (viewOverlayOwnerHiddenRemoved3/3,view-overlay-owner-hidden-removed9/9,view.zod.tsretiredKey21/21).Surface
17 files, +1624 / −203 (1827 changed lines against
origin/main, under the 5000 human-merge threshold). Two files entered by the claim's surface amendment (5861311629):packages/metadata-core/src/artifact-forward-conversion.ts(only theDEFAULT_FLIPS_NOT_REPLAYED_HEREarray and its reason docblock) andpackages/metadata-core/src/artifact-forward-conversion.test.ts(the door pin). Two files the claim did not spell are recorded there as covered:packages/spec/src/conversions/registry.ts(where every D2 conversion lives) andpackages/lint/src/index.ts(the two rule-id exports, required byrule-id-barrel-exports.test.ts). ⛔ Not touched:flow-node-expression-paths.ts,examples/**,packages/metadata-protocol/**,packages/cli/**,packages/runtime/**,packages/rest/**,packages/spec/src/contracts/**, objectui.Pins that carry weight, and the ablations
decision-overlapping-edge-conditions.pin.test.ts(21 tests): two overlapping true edges → exactly one runs, the first declared, the sibling recordsskipped; declaration order decides (the same predicates reversed take the other branch);mode: 'inclusive'→ both run nested, no skipped step; none true →isDefaultruns in both modes; a true edge beside a default passes the default over in both modes; aconditionslist still narrows by label; registration refuses the pair (either member) and a bad value with the spec sentence, inside a loop body too, and the flow is never armed; the four controls register; the rehydration seam leaves the two-branch shape unrewritten whileapplyMetaMigrations(stack, 17, 18)rewrites it; a non-decision node keeps every-true-edge.conversions.test.ts: the fixture pair (2 notices) plus the predicate's edges, region reach, idempotence, the authoring funnel's silence, and the seam refusal with its firing control.lint-flow-patterns.test.ts: both rules, gating vs advisory, controls, regions, no double report through rule (2).migrations.test.ts's census pin sees the D3 entry naming the D2 id.artifact-forward-conversion.test.ts(27a1a4598): a^17.0.0-floor artifact carrying a two-branch decision passes the door with nomodewritten, no notice for the id and the same reference back; the strict parse the door feeds receives nomode;^99.0.0shuts the window; and the same fixture throughapplyConversionswithincludeRetired: trueand no refusal comes back{ mode: inclusive }with the entry's notice (firing control).Both ablations ran from committed state through
scripts/ablation-replace.mjs(anchor hit 1→0, marker 0→1, blob hashes printed), the reading was taken, and restore wasgit checkout HEAD -- ABS_PATHunder a trap, proven bygit diff HEADclean andgit hash-objectequal to the HEAD blob. No dist leg was owed: both suites resolve their subject throughsrc(../engine.jsinside service-automation;./registry.jsinside spec).if (exclusive && anyConditionMet)→if (false && …). Bloba60861d3985717a743cb32c16d9e3ba925dee3c7→f0e25d39262ae22b38ef67b5affbba494c0023bf. Ablated run: 6 failed (exactly the exclusivity, skipped-step, declaration-order, written-exclusive, default-passed-over and seam-runs-exclusive pins), 15 passed (the controls, inclusive, default and registration pins). Restored:a60861d3…on disk and at HEAD.MIN_CONDITIONED_EDGES = 2→3. Blobfd1a7902d480b791e7f53116eb38c97ad268fb78→a03f5cbdf9f742aabf42e8400a1fc5df50b50d1b. Ablated run: 5 failed (the fixture pair, the wiring pin, the two-edge rewrite, the left-alone pin, the seam-refusal firing control), 216 passed. Restored:fd1a7902…on disk and at HEAD.27a1a4598): the id removed fromDEFAULT_FLIPS_NOT_REPLAYED_HERE(anchor 1→0, marker 0→1). Blob16742f49e72eaa98214eca097b9b14cef03e8809→26220cf59c92d7b4daf75a74b17e5a076156503c. Ablated run: 2 failed (the subject leg —modewritten — and the strict-parse leg), 27 passed (the firing control and the negative stayed green). Restored:16742f49…on disk and at HEAD.Tests, at
e92edee5b, every exit captured after a redirectpnpm --filter @objectstack/spec test→ exit 0:Test Files 554 passed (554) · Tests 16366 passed | 1 todo.pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2→ exit 0:Test Files 147 passed (147) · Tests 1782 passed (1782).pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2→ exit 0:Test Files 111 passed (111) · Tests 4313 passed (4313).typecheckfor the same three packages → exit 0 each (check:test-typecheckOK on each test layer).DecisionConfigSchemaoutside these packages:metadata-protocol's JSON-projection walk (a refinement projects byte-identically) andconfig-expression-ledger.test.ts(in the service-automation run above); no other importer of the traversal exists (registerFlowcallers inruntimeandplugin.tsare unchanged call sites).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 114 commands on this branch; all 114 ran one92edee5bwith exit 0 (check:dual-build-cjs-loadsandcheck:type-check-debtfirst answered exit 3, PREREQUISITE NOT MET, until the wholepackages/*closure was built — 71/71 — then 0);--ranreconciliation:114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN.check:generatedon the merged tree: every artifact current aftergen:docs.spec-changes.jsonand the upgrade guide render no protocol-18 id yet (control:report-joined-chart-removed0 hits too), so their green is genuine, not a missed regeneration.27a1a4598, readings at that head:pnpm --filter @objectstack/metadata-core exec vitest run --maxWorkers=2→ exit 0:Test Files 16 passed (16) · Tests 289 passed (289); metadata-coretypecheck→ exit 0. Re-run because the diff reaches them (docblock edits inengine.tsandconversions/registry.ts): service-automationTest Files 147 passed (147) · Tests 1782 passed (1782), specTest Files 554 passed (554) · Tests 16366 passed | 1 todo, both typechecks exit 0; lint is not reached and was not re-run. Gates: the same 114 derived commands (0 added, 0 dropped), all exit 0 on27a1a4598;--ran:114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN.check:type-check-debtfirst refused (exit 3) because metadata-core's cache-restoreddist/was older than its source after the ablation's restore rewrote the file; a directpnpm --filter @objectstack/metadata-core build, as the gate prescribes, and a re-run gave 0.Changeset grade, measured at landing
npm
latest@objectstack/specis17.4.0(npm view, 2026-09-27), whose publishedDecisionConfig.jsondeclaresconditionsonly;.changeset/19867-decision-config-mode.mdand.changeset/20168-…mdare still unconsumed, somodeis unreleased and reaches its first release with the traversal that reads it and the conversion that writes it.Clause-②: yesper the ruling's item 2 (the D2/D3 entries and the two lint rules widen the published surface; nothing published narrows).minorfor@objectstack/spec,@objectstack/service-automationand@objectstack/lint, with the BREAKING banner, the FROM → TO block and the disposition markerregistered flow-decision-mode-inclusive-explicit(the changeset file carries it in the gate's own form).Acceptance notes
27a1a4598): the artifact door's refusal offlow-decision-mode-inclusive-explicit, per the seat's answer A (5861311629).config.conditionstakes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429 (the seat's5861311629):os migrate meta --storedcanonicalizes flow rows through the engine seam that refuses the id, so a Studio decision saved before this release with two or more conditioned out-edges and nomoderuns first-match and is rewritten by nothing; the D3 entry and the changeset say so. This PR does not land before that ruling.skills/objectstack-automation/SKILL.mdline 65 (「routed by edgeconditionpredicates」) stays true and does not mentionmode; governed surface, not touched.VALIDATION_ERROR400 throughflowDefinitionRefusal(unchanged code path); not pinned here, the runtime package is outside this surface.Generated by Claude Code