Skip to content

feat(automation)!: edge-branched decision is exclusive; mode: 'inclusive' takes every branch (#15429) - #20344

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-15429-decision-first-match
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/issue-15429-decision-first-match

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #15429
Clause-②: yes

Carries the maintainer's ruling 5793803317 (「跟主流对齐」, 2026-09-23) as ONE change, routed to domain:spec by 5860007474 and dispatched by the domain:spec seat 4 PM (session_01CiCTczDo7tGhafXjf61dUJ, claim 5860277199). Branch base 10ea9eb2e; origin/main (a78f731ad) merged through os-regen-merge.sh; every reading below is at head e92edee5b unless it says otherwise; the patch commit 27a1a4598 (the seat's answer A in 5861311629) and the ruling C round (5863827385, claim 5864486967, head 18cbf4e2) carry their own readings where stated. (Body revised by the seat at 2026-09-28T01:34Z from the patch-round report 5861745226.)

Coordination card: objectstack-ai/objectui#10750 (the designer offers mode). Nothing is written in objectui here.

What changes

  1. Exclusive by default (ruling item 1). AutomationEngine.traverseNext: on a decision node the conditioned out-edges are evaluated in the order the flow's edges array declares them and the FIRST one whose condition holds is the branch; its later siblings are not evaluated and record the same skipped step a closed gate does (skippedBy names the gate and the edge). isDefault is unchanged: it runs when no conditioned sibling did. Scoped to decision: conditioned out-edges of any other node type keep the every-true-edge traversal (the census below found none).
  2. Explicit inclusive (item 2). config.mode: 'inclusive' takes every out-edge whose condition holds, one successor at a time (never Promise.all; the pin's positive control proves the instrument can see interleaving).
  3. Registration door (acceptance 5857171841). registerFlow parses every decision's config through the spec's DecisionConfigSchema and refuses the flow on any issue rooted at mode — the refinement (mode beside a non-empty conditions list, either member) and the value refusal — with the schema's own sentence at node 'x' (decision) at config.mode, inside ADR-0031 regions too. Judged on mode alone, deliberately: the same parse also refuses an undeclared key, but that strictness binds at authoring by the standing decision in the module header of schemaless-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 than conditions, so promoting it later is cheap.
  4. os validate door. @objectstack/lint gains flow-decision-mode-invalid (gating; the finding IS the schema's issue message, so both doors say one sentence) and flow-decision-inclusive-overlap (advisory, ruling item 4: mode: 'inclusive' with two or more conditioned out-edges; beside flow-decision-unconditional-branch, which is about an out-edge nothing gates).
  5. Migration (item 3). ADR-0087 D2 conversion flow-decision-mode-inclusive-explicit (protocol 18): a decision with no conditions list, no mode, and two or more conditioned out-edges (a fault edge is error routing; a blank condition is none; regions walked through the shared slot table) gets mode: 'inclusive' written, with a notice naming the count. One conditioned edge plus a default is left alone; an authored mode is left alone (idempotent by construction). No inference over the conditions, per the ruling. Paired D3 entry flow-decision-edge-branching-first-match names 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.
  6. Rewrites (item 5). decision-overlapping-edge-conditions.pin.test.ts is the contract pin now, per its own header. flows.mdx, the DecisionConfigSchema docblock and mode describe, the module header (the declared-ahead sites of 5852576993 item 2), logic-nodes.ts, and engine.ts's traversal comment all describe both modes; the reference mdx is regenerated.

Ruling C (5863827385): stored rows take the new meaning — listed, not rewritten

The ruling, verbatim:

Ruled: C. Ruling 5793803317 item 3 (「保证已上线的流程行为不变」) is narrowed to the surfaces that can carry it: authored sources (os migrate meta --from 17) and built artifacts. Stored rows (sys_metadata) take the new meaning on upgrade — a decision node with no config.conditions, no mode and two or more conditioned out-edges evaluates first-match — and the changeset and the upgrade guide state that as BREAKING, naming the shape and the one-line fix (mode: 'inclusive') for a node that meant every branch. No stored-row rewrite, no cutoff, no read-path completion. A (write-explicit on save plus read-path completion of a missing mode to inclusive) and B (an operator-supplied cutoff) are not taken.

Execution parameters (ruled in the same stroke):

  • PR feat(automation)!: edge-branched decision is exclusive; mode: 'inclusive' takes every branch (#15429) #20344 (draft, 6bc84ba59, CI green) lands after the at-tier contract review (Clause-② yes), with these edits in its next round: the D3 entry's and the changeset's 「judgment owed」 sentence replaced by this ruling; a BREAKING paragraph naming the stored-row shape and the mode: 'inclusive' fix; os migrate meta --stored lists — report only, no --apply effect — every stored decision node with two or more conditioned out-edges and no mode, so an operator can review candidates before and after the upgrade. ADR-0087 needs no addendum: the artifact-door section's premise holds for sources and artifacts, and the stored seam is stated in the changeset.
  • objectui#10750 (the designer writes mode explicitly on save) proceeds on its own card; it is the same question's other end and is wanted under this letter too.
  • Pins: a stored decision node lacking mode with overlapping conditions evaluates first-match after upgrade; the --stored report lists it and changes nothing; a source flow migrated with --from 17 carries explicit mode: 'inclusive' and still takes every branch.

What this round did: (a) prose — the D3 entry flow-decision-edge-branching-first-match (reason tail and acceptance sentence), the D2 docblock, step18's rationale and the changeset now state BREAKING for a stored decision with no config.conditions, no mode and two or more conditioned out-edges, the one-line fix mode: 'inclusive', and the listing; the upgrade guide renders the D3 entry (reason = Why not automatic, acceptanceCriteria = Done when) when protocol 18 is cut; DecisionConfigSchema.mode's describe/docblock and the traversal comment say 'authored sources'; flows.mdx gains a stored-flow callout and cli.mdx a --stored paragraph; (b) listing — StoredMigrationReport.decisionModeReview (StoredDecisionModeReview: row id, flow, org, package, state, node id, label, path) in @objectstack/metadata-protocol, filled for every flow row on preview and apply alike by collectDecisionModeReview, which runs the D2 entry's own apply over the stored body and discards the result (one predicate; no engine needed; throws if the entry leaves the registry); it moves no outcome, count, exit code or write; formatStoredMigrationReport prints it with the fix, and the CLI and POST /meta/_migrate-stored carry it unchanged, so meta.ts is not edited; (c) the ruling's three pins, named below.

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 the config.conditions first-match in builtin/logic-nodes.ts (untouched, still label-narrowing). Declaration order is flow.edges array order: traverseNext filters that array in place; FlowSchema.parse (region transform included) and every conversion walker are copy-on-write maps that never reorder; normalizeStackInput normalizes map-form collections at the stack level and never touches a flow's edges; canonicalizeStoredFlow runs those same two. Grep for any edge re-sort across packages/*/src and packages/*/*/src (edges.sort, sortEdges, .edges.slice().sort, localeCompare on 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, saveMetaItem canonicalizes without reordering.

2. The migration predicate — census. Corpus: this repository's examples at 10ea9eb2e and objectstack-ai/hotcrm at 2f7b2326 (read-only), every flow module loaded and every graph walked including regions; platform packages ship no decision node (grep over packages/platform-objects, plugins, services: only the executor and the README).

corpus flows decisions rewritten (no list, ≥ 2 conditioned, no mode) left alone non-decision nodes with a conditioned out-edge
examples (app-crm, app-todo, app-showcase) 35 5 4 1 (crm_convert_lead_wizard.check_converted: 1 conditioned + isDefault) 0
hotcrm 13 25 13 (incl. lead_conversion.decision_duplicate, the #1555 node, now three conditioned edges) 12 (single-conditioned guards, no default) 0

Every one of the 17 positives is a hand-written partition by inspection (a predicate beside its negation, > beside <=, has() beside !has(), hotcrm's CASE_HAS_OWNER beside 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 than conditions. 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 — canonicalizeStoredFlow is reached by the boot pull for code-shipped flows, by POST /automation, by saveMetaItem (every Studio save) and by duplicatePackage, 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's DEFAULT_FLIPS_NOT_REPLAYED_HERE for app-hidden-to-unpublished on #17885, the WITHDRAWN field-required-notnull-explicit note) says a seam that cannot state 「this body predates the flip」 refuses the entry by id. So:

  • the entry is 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;
  • canonicalizeStoredFlow refuses it by id (CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, reason at the call site), pinned on parsed, storable and notices, with the chain as the firing control;
  • Stored sys_metadata flows take the new meaning — ruled C (5863827385). A decision saved before this release with two or more conditioned out-edges and no mode runs first-match after the upgrade; no pass rewrites it (no stored-row migration, no cutoff, no read-path completion). BREAKING, stated in the changeset and the D3 entry with the one-line fix mode: 'inclusive'; os migrate meta --stored lists every such node, report only.
  • The artifact-ingestion door refuses it too (patch commit 27a1a4598, the seat's answer A in 5861311629). packages/metadata-core/src/artifact-forward-conversion.ts lists flow-decision-mode-inclusive-explicit in DEFAULT_FLIPS_NOT_REPLAYED_HERE beside the app-hidden-to-unpublished precedent, with its reason: the door's trigger is the artifact's declared engines.protocol floor, ^17.0.0 is what create-objectstack stamps, 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/main gained #20286 (view-overlay-owner-hidden-removed) on the same registry lines; os-regen-merge.sh merged it (both entries kept in landing order in conversions/registry.ts and in MIGRATIONS_BY_MAJOR[18], rationale concatenated), gen:migration-registry regenerated to an identical file, check:generated found every artifact current, and every sibling symbol was asserted present on both sides by exact-name grep (viewOverlayOwnerHiddenRemoved 3/3, view-overlay-owner-hidden-removed 9/9, view.zod.ts retiredKey 21/21).

5. Ruling C round (dispatch assumptions). (1) The report type is metadata-protocol's, not a spec contract type (api/protocol.zod.ts, ruling 2C note), so the widening is StoredMigrationReport + StoredDecisionModeReview there, covered by Clause-②: yes; the renderer is also metadata-protocol's, so meta.ts needed no edit. (2) The listing reads the stored body directly, with no engine, through the D2 entry's apply by id; a flow row skipped for want of an engine still lists. (3) origin/main 15bf186f5 (32 commits) merged through os-regen-merge.sh as df3f6a00: two hand-written conflicts (both registries; main's form-layout-inline-grid-to-vertical, currency-config-precision-removed, permission-rls-tags-removed kept ahead of ours), the reference mdx regenerated (62771d8e), gen:migration-registry a no-op on the merged entries, sibling ids and symbols asserted present 2/2 and 2/2. (4) #20316: branch claude/issue-20316-flow-node-config-build-doors exists, no PR; overlap with this PR is conversions.test.ts and migrations/registry.ts only; nothing here touches registerFlow.

Surface

22 files, +2157 / −205 (2362 changed lines against merge base 15bf186f5, 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 the DEFAULT_FLIPS_NOT_REPLAYED_HERE array and its reason docblock) and packages/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) and packages/lint/src/index.ts (the two rule-id exports, required by rule-id-barrel-exports.test.ts). ⛔ Not touched: flow-node-expression-paths.ts, examples/**, packages/cli/**, packages/runtime/**, packages/rest/**, packages/spec/src/contracts/**, objectui. The ruling C round adds packages/metadata-protocol/src/{stored-migration,protocol,index}.ts, protocol.stored-migration.test.ts and content/docs/deployment/cli.mdx. ⛔ Still not touched: packages/cli/**, packages/runtime/**, packages/rest/**, packages/spec/src/contracts/**, examples/**, docs/adr/**, 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 records skipped; declaration order decides (the same predicates reversed take the other branch); mode: 'inclusive' → both run nested, no skipped step; none true → isDefault runs in both modes; a true edge beside a default passes the default over in both modes; a conditions list 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 while applyMetaMigrations(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 no mode written, no notice for the id and the same reference back; the strict parse the door feeds receives no mode; ^99.0.0 shuts the window; and the same fixture through applyConversions with includeRetired: true and no refusal comes back { mode: inclusive } with the entry's notice (firing control). Ruling C (5863827385): decision-overlapping-edge-conditions.pin.test.ts — a decision as a pre-18 row stores it (JSON text, no mode, the hotcrm#1555 pair) passes canonicalizeStoredFlow with no mode written, registers and runs FIRST-MATCH (second branch skipped on its edge); the same body through applyMetaMigrations(stack, 17, 18) carries mode: inclusive and runs EVERY branch, nested, no skipped step (22 tests). protocol.stored-migration.test.ts — the stored node is listed with row, flow, node, label and path while the row stays canonical and clean; --apply changes no byte and writes no history; a row rewritten for another conversion persists no mode; no engine still lists; region path; controls (either mode, one edge plus default, conditions list, fault edge); the list equals the --from 17 chain's write paths; the renderer prints it beside the on-protocol verdict.

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 was git checkout HEAD -- ABS_PATH under a trap, proven by git diff HEAD clean and git hash-object equal to the HEAD blob. No dist leg was owed: both suites resolve their subject through src (../engine.js inside service-automation; ./registry.js inside spec).

  • traversal: if (exclusive && anyConditionMet) → if (false && …). Blob a60861d3985717a743cb32c16d9e3ba925dee3c7 → 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.
  • predicate: MIN_CONDITIONED_EDGES = 2 → 3. Blob fd1a7902d480b791e7f53116eb38c97ad268fb78 → 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.
  • artifact door (27a1a4598): the id removed from DEFAULT_FLIPS_NOT_REPLAYED_HERE (anchor 1→0, marker 0→1). Blob 16742f49e72eaa98214eca097b9b14cef03e8809 → 26220cf59c92d7b4daf75a74b17e5a076156503c. Ablated run: 2 failed (the subject leg — mode written — and the strict-parse leg), 27 passed (the firing control and the negative stayed green). Restored: 16742f49… on disk and at HEAD.
  • stored listing (18cbf4e2): protocol.ts for (const node of collectDecisionModeReview(body)) { emptied (.slice(0, 0)), blob 711fded5ddea7d37b4f6d2a52f7b7a6a80db8081 → ce0c296d5134527c0634c1932ec88b59c6285dbc; ablated run 5 failed | 34 passed (the five listing pins; region, controls, parity and the nothing-to-review control green); restored 711fded5… on disk and at HEAD.
  • stored-row seam (18cbf4e2): engine.ts excludeConversionIds: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, removed (a read-path completion), blob daac6de07304ae4051f1681ab4311c447a8ad3a9 → a3555237ccca29ff4ad888bd009cc97be4007ae8; ablated run 7 failed | 15 passed (the ruling C stored-row pin, both seam pins, four exclusive-traversal pins; the --from 17 source pin, inclusive, default, registration and boundary green); restored daac6de0… on disk and at HEAD.

Tests, at e92edee5b, every exit captured after a redirect

  • pnpm --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).
  • typecheck for the same three packages → exit 0 each (check:test-typecheck OK on each test layer).
  • Importers of DecisionConfigSchema outside these packages: metadata-protocol's JSON-projection walk (a refinement projects byte-identically) and config-expression-ledger.test.ts (in the service-automation run above); no other importer of the traversal exists (registerFlow callers in runtime and plugin.ts are unchanged call sites).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 114 commands on this branch; all 114 ran on e92edee5b with exit 0 (check:dual-build-cjs-loads and check:type-check-debt first answered exit 3, PREREQUISITE NOT MET, until the whole packages/* closure was built — 71/71 — then 0); --ran reconciliation: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. check:generated on the merged tree: every artifact current after gen:docs. spec-changes.json and the upgrade guide render no protocol-18 id yet (control: report-joined-chart-removed 0 hits too), so their green is genuine, not a missed regeneration.
  • Patch commit 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-core typecheck → exit 0. Re-run because the diff reaches them (docblock edits in engine.ts and conversions/registry.ts): service-automation Test Files 147 passed (147) · Tests 1782 passed (1782), spec Test 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 on 27a1a4598; --ran: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. check:type-check-debt first refused (exit 3) because metadata-core's cache-restored dist/ was older than its source after the ablation's restore rewrote the file; a direct pnpm --filter @objectstack/metadata-core build, as the gate prescribes, and a re-run gave 0.
  • Ruling C round, at 18cbf4e2: spec test 557 passed (557) · 16508 passed | 1 todo; spec test:repo 35 passed (35) · 634 passed (634); metadata-protocol 189 passed | 3 skipped (192) · 2745 passed | 19 skipped (2764); service-automation 147 passed (147) · 1783 passed (1783); metadata-core 16 passed (16) · 289 passed (289); lint 113 passed (113) · 4713 passed (4713); cli unit 230 passed plus the two published-subpath pins 2 passed (2) · 29 passed (29) after a post-merge pnpm install (prerequisite, not a red); typecheck exit 0 for all six. Gates: 117 derived, 117 run, --ran: 117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN (three first answered exit 3 PREREQUISITE NOT MET until the full ./packages/* closure and client-react / metadata-protocol were built). Narrowed eslint over the PR's 18 changed .ts files: 0 errors, 0 warnings (no type-aware linting in eslint.config.mjs). CI on 18cbf4e2: 33 success, 2 skipped, all seven required contexts success.

Changeset grade, measured at landing

npm latest @objectstack/spec is 17.4.0 (npm view, re-measured 2026-09-28), whose published DecisionConfig.json declares conditions only; .changeset/19867-decision-config-mode.md and .changeset/20168-…md are still unconsumed, so mode is unreleased and reaches its first release with the traversal that reads it and the conversion that writes it. Clause-②: yes per the ruling's item 2 (the D2/D3 entries and the two lint rules widen the published surface; nothing published narrows). minor for @objectstack/spec, @objectstack/service-automation and @objectstack/lint, with the BREAKING banner, the FROM → TO block and the disposition marker registered flow-decision-mode-inclusive-explicit (the changeset file carries it in the gate's own form). @objectstack/metadata-protocol minor (the report widens) and @objectstack/metadata-core patch (the artifact door's refusal) join the bump list; the changeset carries the ruling C BREAKING section.

Acceptance notes

  • Landed on this PR (27a1a4598): the artifact door's refusal of flow-decision-mode-inclusive-explicit, per the seat's answer A (5861311629).
  • The stored-row half is ruled C (5863827385) and landed in this round: stored rows take the first-match meaning (BREAKING, stated with the fix), os migrate meta --stored lists them report-only, and the three pins hold it. objectui#10750 (the designer writes mode on save) proceeds on its own card and is not this PR.
  • origin/main moved two commits after this round's merge (0d7ed5a3 regenerates packages/spec/src/migrations/registry.ts); the landing lap merges it through os-regen-merge.sh.
  • skills/objectstack-automation/SKILL.md line 65 (「routed by edge condition predicates」) stays true and does not mention mode; governed surface, not touched.
  • The REST door answers a registration refusal as VALIDATION_ERROR 400 through flowDefinitionRefusal (unchanged code path); not pinned here, the runtime package is outside this surface.

Generated by Claude Code

…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>
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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/lint, @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/spec, touching 47 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/metadata-protocol/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/metadata-protocol/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (6 routes)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 137 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 40b315b03345e334069dd454aecaf7016adbea4f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e82b32af5b2ece5e03fe7be92c4ab5cc2caac653 — the merge of head b30325bb385e5b4da8e31538ea7931550ec1cd56 into base 40b315b03345e334069dd454aecaf7016adbea4f, 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 e82b32af5b2ece5e03fe7be92c4ab5cc2caac653 && git checkout e82b32af5b2ece5e03fe7be92c4ab5cc2caac653
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40b315b03345e334069dd454aecaf7016adbea4f b30325bb385e5b4da8e31538ea7931550ec1cd56 && git checkout -B drift-repro 40b315b03345e334069dd454aecaf7016adbea4f && git merge --no-ff b30325bb385e5b4da8e31538ea7931550ec1cd56

node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f

⚠️ 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 40b315b03345e334069dd454aecaf7016adbea4f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
origin/main 15bf186 is 32 commits past the merge base a78f731. Both
registries gained protocol-18 entries on each side at the same tail:
main's `form-layout-inline-grid-to-vertical`,
`currency-config-precision-removed` and `permission-rls-tags-removed`,
this branch's `flow-decision-mode-inclusive-explicit`. All kept, landing
order (main's first) in `CONVERSIONS_BY_MAJOR[18]`, in step18's
`conversionIds`, and in step18's hand-written rationale. The conversion
block and its region-slot import were re-applied onto main's file whole;
the generated semantic regions are regenerated in the next commit.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
…erged tree

Discharges the merge's os-regen deferral. The driver kept this branch's
side of the generated reference; regenerated from the merged sources
(`pnpm --filter @objectstack/spec build && gen:docs`), it carries this
branch's decision-mode prose AND main's derived frontmatter description.
`gen:migration-registry` over the merged entries was a no-op (303
semantic, 221 retired-key, 199 retired-def), so the textual merge of its
generated regions was already exact.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
… on upgrade (BREAKING), named with its one-line fix

Maintainer ruling letter C on #15429 narrows the first ruling's
"shipped flows keep their behaviour" to the surfaces that can carry it:
authored sources (`os migrate meta --from 17`) and built artifacts. A
decision stored in `sys_metadata` with no `config.conditions`, no `mode`
and two or more conditioned out-edges evaluates first-match after the
upgrade; no stored-row rewrite, no cutoff, no read-path completion.

- D3 entry `flow-decision-edge-branching-first-match`: the "rewritten by
  nothing" reason tail and the "half no command reaches" acceptance
  sentence are replaced by the ruling — BREAKING for stored rows, the
  shape, the `mode: 'inclusive'` fix, and the `--stored` review list.
  The upgrade guide renders this entry (reason = "Why not automatic",
  acceptanceCriteria = "Done when") once protocol 18 is cut.
- step18 rationale (hand-written, same registry file): one BREAKING
  sentence for stored flows; the artifact door named beside the seam.
- D2 docblock: "the judgment still owed" replaced by the ruling, and the
  review list's reuse of this entry's `apply` stated.
- Changeset: the stored-row paragraph becomes a BREAKING section naming
  the shape, the fix and the listing; `@objectstack/metadata-protocol`
  (minor, the report widens) and `@objectstack/metadata-core` (patch, the
  artifact door's refusal from the previous round) join the bump list.
- `DecisionConfigSchema.mode` describe + docblock and the engine's
  traversal comment say "authored sources" where they said "flows", and
  name the stored-row reading; flows.mdx gains the stored-flow callout.

Generated artifacts (registry regions, reference mdx) follow in their
own commit.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
…d decision that takes first-match since protocol 18 — report only

Ruling C's execution parameter: `--stored` lists, with no `--apply`
effect, every stored decision node with two or more conditioned
out-edges and no `mode`, so an operator can review candidates before
and after the upgrade.

- `StoredMigrationReport.decisionModeReview: StoredDecisionModeReview[]`
  (row id, flow name, org, package, state, node id, label, path). The
  report type lives in this package, not in `packages/spec` (the spec's
  own note at `api/protocol.zod.ts` says so), so the widening is this
  package's exported type, covered by `Clause-②: yes`.
- `collectDecisionModeReview(body)` runs the D2 entry
  `flow-decision-mode-inclusive-explicit`'s own `apply` over the stored
  body and keeps only the paths it would write, discarding the result:
  one predicate, so the list is exactly what `--from 17` rewrites in a
  source, regions included. It reads the STORED body before and apart
  from the flow canonicalizer, so it needs no engine: a host with no
  automation service (flow row `skipped`) still lists. It throws if the
  entry leaves the registry, rather than reporting an empty list.
- `migrateStoredMetadata` fills it for every flow row, preview and apply
  alike; it moves no outcome, count, verdict or write.
- `formatStoredMigrationReport` prints the list with the one-line fix,
  beside the on-protocol verdict. The CLI renders through this function
  and spreads the report into `--json`, and `POST /meta/_migrate-stored`
  answers the same object, so no CLI edit is needed.
- `cli.mdx`'s `--stored` section documents the list.
- Pins in `protocol.stored-migration.test.ts`: listed and canonical;
  `--apply` changes nothing (bytes, history); a row rewritten for another
  conversion persists no `mode`; no engine still lists; region path;
  controls (declared `mode` either way, one edge plus default,
  `conditions` list, `fault` edge); one-predicate parity with the
  `--from 17` chain; renderer.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
…mode` runs first-match; a `--from 17` source keeps every branch

Two of ruling C's three pins, in the decision contract pin file:

- A decision as a pre-18 `sys_metadata` row carries it (JSON text, no
  `mode`, the hotcrm#1555 overlapping pair) goes through
  `canonicalizeStoredFlow` with no `mode` written and no notice for the
  id, then registers and runs FIRST-MATCH: only the first declared
  branch runs, the second records a `skipped` step on its edge.
- The same body through `applyMetaMigrations(..., 17, 18)` carries
  explicit `mode: 'inclusive'` and, registered, still takes EVERY
  branch, nested, with no skipped step (extends the former firing
  control, which only read the written key).

The third pin (the `--stored` report lists it and changes nothing) is in
`@objectstack/metadata-protocol`.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
…e-config reference after the ruling C prose

`gen:migration-registry` concatenates the edited D3 entry into
`registry.ts`'s semantic:18 region; `build && gen:docs` re-renders the
`mode` describe. `check:generated`: all 15 artifacts up to date on the
regenerated tree.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 18cbf4e2b923dd530304235a39924fdf6043c395
Local-runs: none

① Derived judgments

Inputs read in full: card #15429 (body + all 24 comments, rulings 5793803317 and 5863827385 included), PR #20344 body and 22-file list, the net diff against merge base 15bf186f5 (+2157/−205, 13 branch commits), and the check-runs on the head. Check-runs: 42 on the head; at first read ONE was in progress — Check Changeset (re-run started 07:51:24Z; its earlier run on the same head concluded success at 06:58:49Z); on a re-poll before this record was rendered it concluded success at 07:52:25Z, leaving 38 success · 4 skipped (Console Pin Gate, Packed-tarball smoke opt-in, and Auto Label / Check PR Size on the re-run) · 0 failure · 0 in progress. Every accept-set and public-surface change the diff implies, judged:

  1. Traversal (engine.ts traverseNext) — RIGHT. exclusive = node.type === 'decision' && config.mode !== 'inclusive'; once a conditioned out-edge holds, every later sibling is passed over unevaluated and records the same skipped step a closed gate does (skippedBy names gate + edge, via the hoisted recordSkipped); anyConditionMet still gates isDefault; the unconditional bucket's Promise.all and non-decision nodes are untouched. Declaration order is flow.edges array order (filtered in place; parse and conversions are copy-on-write). Ruling item 1 verbatim. One scope note, not a defect: the flag keys on node type, not on "no config.conditions", so a conditions-list decision whose label-narrowed edges hold two or more conditioned edges under one label also goes first-match now — the D3 text says "nothing else about conditions-list decisions changes"; the corpus has zero such nodes and the direction matches the ruling.
  2. mode: 'inclusive' — RIGHT. Every true edge, sequential (await inside the loop), never Promise.all; pinned nested-not-interleaved with the unconditional fan-out as the positive control. Ruling item 2.
  3. Registration door (validateDecisionModes) — RIGHT. Every decision in every graph (regions via collectFlowGraphs) is parsed through DecisionConfigSchema.safeParse; the flow is refused on issues rooted at mode only, with the schema's own sentence at node 'x' (decision) at config.mode, region-prefixed; hard-fail, never armed (getFlow null pinned). Acceptance 5857171841 (refinement + value prescription, both doors). Judging mode alone and leaving unknown-key strictness at authoring is a declared, reasoned scope (module header's standing decision; measured reach zero).
  4. os validate — RIGHT. flow-decision-mode-invalid (severity error; finding IS the schema issue message; hint names registerFlow) and flow-decision-inclusive-overlap (advisory; two or more conditioned non-fault out-edges; one edge plus default excluded), both barrel-exported, region-reaching, no double report with flow-decision-unconditional-branch. Ruling item 4.
  5. D2 conversion flow-decision-mode-inclusive-explicit — RIGHT. toMajor: 18, retiredFromLoadPath: true, predicate = decision with no non-empty conditions, no mode key, two or more conditioned non-fault out-edges (string or {dialect, source} envelope; blank = none), regions through FLOW_REGION_SLOTS_BY_TYPE with a depth ceiling, copy-on-write, one notice per write, fixture pair with 2 notices; wired into CONVERSIONS_BY_MAJOR[18] and MIGRATIONS_BY_MAJOR[18].conversionIds. No alias, no transition window, no inference over CEL — the ruling's count predicate. Idempotent by construction. Ruling item 3, landed in the SAME PR as the semantics ("never apart" held).
  6. D3 entry 18.flow-decision-edge-branching-first-match.ts + regenerated migrations/registry.ts — RIGHT. Names the D2 id as a whole word; reason and acceptanceCriteria carry ruling C in substance (BREAKING for stored rows, no rewrite / cutoff / read-path completion, the one-line fix, the --stored listing); step18 rationale gains the sentence.
  7. Replay sites of the flip id, each judged:
    • authored sources, os migrate meta --from 17 = applyMetaMigrations(stack, 17, 18): REPLAYS — pinned (conversions.test.ts chain wiring; the ruling-C source pin: migrated flow carries { mode: 'inclusive' } and takes every branch). Right.
    • authoring funnel normalizeStackInput: refused by retiredFromLoadPath — pinned. Right.
    • engine rehydration seam canonicalizeStoredFlow: applyConversionsToFlow(…, { includeRetired: true, excludeConversionIds: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION }); spec/conversions/apply.ts:115,130-132 honours the option; pinned on parsed, storable and notices with the chain as firing control; the dev's ablation (exclusion removed = a read-path completion) turned 7/22 red. Refused — right.
    • artifact door applyArtifactForwardConversions: DEFAULT_FLIPS_NOT_REPLAYED_HERE (fed to excludeConversionIds at line 361) gains the id beside app-hidden-to-unpublished with its reason; four-leg pin (subject at ^17.0.0, the strict parse the door feeds, ^99.0.0 negative, firing control through applyConversions with the window open). Refused — right; the seat's answer A 5861311629 executed on this PR in 27a1a4598.
    • stored pass migrateStoredMetadata: flow rows never reach applyConversionsToStoredItem (protocol.ts:4780 returns flow bodies untouched); they go through the caller's canonicalizer (the engine seam above) or are skipped; collectDecisionModeReview runs the D2 entry's own apply over { flows: [body] }, keeps only the emitted paths and discards the stack (throws if the entry leaves the registry or emits an unknown path shape). Listing only — pinned: row/flow/node/label/path listed with the row canonical and stored bytes identical; --apply writes nothing and no history row; a row rewritten for another conversion persists no mode; no engine still lists; region path; five controls; ONE-PREDICATE parity with the chain's write paths; renderer beside the on-protocol verdict; storedMigrationClean unmoved. Right.
    • enumeration closed: includeRetired: true outside tests occurs at exactly three sites at the head (artifact door, engine seam, spec/conversions/stored.ts:104 item primitive); the item primitive's only non-spec callers are core/fallbacks/authored-translation-sync.ts (translation type) and protocol.ts:4783 behind the flow short-circuit. No fourth seam.
  8. Report widening (@objectstack/metadata-protocol) — RIGHT. StoredMigrationReport.decisionModeReview (required array) + exported type StoredDecisionModeReview; formatStoredMigrationReport prints the section only when non-empty, with the fix sentence; describeScope widened to a Pick. CLI migrate/meta.ts:708-732 formats through that renderer and spreads the report into --json; REST rest-server.ts:5854-5866 answers res.json(report); the SDK's meta.migrateStored is deliberately unbound (spec/api/protocol.zod.ts:1651, ruling 2C) — so no spec contract type exists to widen and meta.ts needed no edit; the dev's reading holds. No other constructor of the report type at the head, so the required field breaks no consumer (workspace type-check green).
  9. Prose — RIGHT, with one residue. Module header, the former "Declared ahead of its enforcement" paragraph (now "Where mode is honoured"), the mode describe and docblock, flows.mdx ("BPMN gateway — the default choice", the exclusive/inclusive paragraphs, the upgrade and stored-flow callouts), cli.mdx --stored paragraph, logic-nodes.ts and the engine.ts traversal comment (the "mutually exclusive" comment is now true — item 5), regenerated reference mdx (describe text byte-identical; check:generated green). Residue: schemaless-node-config.zod.ts:451, the DecisionConfigSchema docblock's opening line, still reads "the branch mode declared ahead of the engine change that will read it" — source-only (it reaches neither the generated reference nor flows.mdx) and contradicted by the head's own next section. Flagged in ③, non-blocking.
  10. Pin rewrite — RIGHT. decision-overlapping-edge-conditions.pin.test.ts is the contract pin per its own header (22 tests, the ruling-C pair included). Item 5.
  11. examples/** not rewritten — RIGHT. Chain-only conversion; the 4 in-tree positives all partition, so first-match changes no run at boot.

② Semver level

Changeset .changeset/15429-decision-edge-branching-first-match.md: @objectstack/spec minor · @objectstack/service-automation minor · @objectstack/lint minor · @objectstack/metadata-protocol minor · @objectstack/metadata-core patch; feat(automation)!: title; the adr-0087 "registered" disposition marker naming flow-decision-mode-inclusive-explicit; Clause-②: yes; BREAKING banner, FROM → TO block, and a second BREAKING section for stored rows (ruling C). Check Changeset success twice on the head.

③ Boundary flags

Dev flags — newest os-dev-report 5865679628, deviations, each answered:

  1. origin/main merged first (df3f6a00), no force-push — fine; every reading on the merged tree.
  2. Mechanism assumption 1 partly falsified (renderer and report type live in metadata-protocol; meta.ts untouched) — verified at the head (①.8). Right.
  3. Mechanism assumption 2 (listing reads the stored body with no engine, through the entry's apply) — verified; guarded against a silently empty list. Right.
  4. Surface additions (cli.mdx paragraph, metadata-protocol/src/index.ts type export, mode describe/docblock narrowed to "authored sources", engine.ts comment-only) — verified: between 62771d8e and the head engine.ts moves no non-comment line and nothing enters registerFlow. Within claim 5864486967; accepted.
  5. Changeset gains @objectstack/metadata-core: patch — right (②).
  6. CLI unit prerequisite refusals (no dist, then post-merge workspace deps) — prerequisite, not a red; CI Test Core shards green on the head.
  7. origin/main moved after the merge — now FIVE commits past the merge base (0d7ed5a3 regenerates migrations/registry.ts and rewords driver-/kernel-/system-* entries; ab6fb027 docs cli; 2f122b6e objectql; 681868ca spec ui; df3ba164 plugin-email). Nothing in this head depends on any of them: the diff references none of their content, its tests and gates ran on the merged tree at 15bf186f5, and the only file both sides touch is the generated packages/spec/src/migrations/registry.ts in non-overlapping regions (PR hunks at step18 lines 5414 / 5457 / 10414; main's at step17 2376–2506 and step18 8215–8406, 11052–11467). GitHub reports mergeable: true against the current tip (df3ba164, 06:39Z, before this read), and a git trial merge of origin/main into the head (tree objects only, no worktree) is conflict-free. The landing lap is a merge plus a confirming gen:migration-registry / check:generated, expected no-op since the new semantic entry sorts into its own position.

open_questions: none in the ruling-C report. The earlier round's one question (which route lands the artifact door's refusal) was answered A by 5861311629 and landed in 27a1a4598; verified (①.7).

Ruling coverage:

  • 5793803317 items 1–5 all landed in this one PR (traversal; explicit inclusive; D2 + D3 with the semantics, no alias, no window; lint hint; the rewrites).
  • 5863827385 (letter C), execution parameters: the "judgment owed" / "rewritten by nothing" / "half no command reaches" sentences are gone from the head (zero hits across packages, content, .changeset, docs); the BREAKING paragraph naming the stored-row shape and mode: 'inclusive' is in the changeset, the D3 entry, step18's rationale, the flows.mdx callout and cli.mdx; os migrate meta --stored lists report-only with no --apply effect (bytes-identical pinned); no stored-row rewrite, no cutoff flag, no read-path completion; no ADR edit (docs/adr/** untouched, as ruled); the three named pins are present (stored no-mode overlapping decision runs first-match; the --stored report lists it and changes nothing; a --from 17 source carries explicit mode: 'inclusive' and still takes every branch). The generated docs/protocol-upgrade-guide.md renders no protocol-18 step while PROTOCOL_VERSION is 17.0.0; the BREAKING text is carried by the D3 entry's reason / acceptanceCriteria, which is what the guide renders at the cut — satisfied by construction, visible at the cut.
  • objectui#10750 proceeds on its own card, as ruled; nothing here reaches objectui.

Escalations: none. Flagged for the landing lap, non-blocking: the one-line docblock residue at packages/spec/src/automation/schemaless-node-config.zod.ts:451 ("declared ahead of the engine change that will read it"), which no generated artifact carries.

Implemented-by: claude/issue-15429-decision-first-match
Reviewed-by: session_01ARcDurZ5j34RdqsGgc4jgH

VERDICT: PASS


Generated by Claude Code

Landing lap: origin/main dcd3bce, 6 commits past 15bf186. #20398
(`dcd3bcea`) appended `action-aria-removed` to the same three step-18
tails this branch appends `flow-decision-mode-inclusive-explicit` to.
Resolved per the seat's answer B on #15429 (5865957805), and nowhere else:

- `CONVERSIONS_BY_MAJOR[18]` and step18 `conversionIds`: both kept,
  main's `actionAriaRemoved` / `action-aria-removed` first (landing
  order), this branch's entry after it;
- step18 `rationale`: main's sentence kept, this branch's sentence
  appended verbatim (the one string join: main's closing literal now
  ends in a space and the concatenation continues).

No other hand edit; generated regions are regenerated in the next commit.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0c4dad2a60b3f6aa189ebe3856afb44a7458f9ba
Local-runs: none

① Derived judgments

Inputs read in full: card #15429 (body + all 27 comments — rulings 5793803317 and 5863827385, resolution B 5865957805, claim 5864486967, reports 5865679628 / 5865931103 / 5866729342 included), PR #20344 body and 22-file list, the net diff against main at dcd3bcea (dcd3bcea IS the merge base, so two-dot equals three-dot: 22 files, +2157 / −205, path-for-path and count-for-count equal to the PR's file list), and the check-runs on this head: 35 — 33 success · 2 skipped (Console Pin Gate, Packed-tarball smoke opt-in) · 0 failure · 0 in progress; Check Changeset, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, every Type Check and Test Core shard, both Dogfood gates and Temporal Conformance all success.

The merge resolution, judged first (head = merge commit, parents 18cbf4e2 and dcd3bcea; no regeneration commit followed):

  • (a) main's side — RIGHT. git diff 15bf186f5 dcd3bcea versus git diff 18cbf4e2 0c4dad2a on the two registries, compared as +/− line multisets: 508 versus 506 lines, and the only lines not common are the one string join — main's closing literal '`action-aria-retired`.', is '`action-aria-retired`. ' at the head so the concatenation continues. No line of main's dropped, reordered or reworded otherwise (main's own permission-rls-tags-retired join, which main had already opened for action-aria, is on both sides). This PR's side — RIGHT. git diff 15bf186f5 18cbf4e2 versus git diff dcd3bcea 0c4dad2a on the same two paths: 374 versus 374 lines, identical except that the join this PR opens is now action-aria-retired's instead of permission-rls-tags-retired's; every one of its 294 + 79 added lines is present. Order at the head: CONVERSIONS_BY_MAJOR[18] ends permissionRlsTagsRemoved, actionAriaRemoved, flowDecisionModeInclusiveExplicit (conversions/registry.ts:12206-12208); step 18 conversionIds ends 'permission-rls-tags-removed', 'action-aria-removed', 'flow-decision-mode-inclusive-explicit' (migrations/registry.ts:5500-5502) — resolution B's landing order, and nowhere else was hand-edited.
  • (b) the resolved step-18 rationale — true, with one cosmetic seam. At migrations/registry.ts:5433-5458 main's sentence ("Finally, it removes aria from the action … its D3 record is the semantic entry action-aria-retired. ") is followed by this PR's sentence verbatim ("Finally it makes edge-branched decision nodes EXCLUSIVE … mode: 'inclusive' is the one-line fix where a node meant every branch."). Every sentence in the sequence is true on this head; build-upgrade-guide.ts:89 prints step.rationale as one paragraph, so the reader at the protocol-18 cut will see two consecutive "Finally" openers. That is the cost resolution B priced in ("carried to the next PR that touches step 18's rationale"), and rewording main's opener would have breached B's own rule. Non-blocking; restated in ③.
  • (c) no other file moved — RIGHT. git diff --numstat 15bf186f5...18cbf4e2 and git diff --numstat dcd3bcea...0c4dad2a are identical for all 22 files (diff of the two lists empty; +2157 / −205 both). Stronger than the numstat: of the 22 files, main moved only the two registries between 15bf186f5 and dcd3bcea; the 20 non-registry files have byte-identical blobs at 18cbf4e2 and at this head (git diff --name-only 18cbf4e2 0c4dad2a over those 20 paths is empty); and git diff --name-only dcd3bcea 0c4dad2a is exactly the 22. The regeneration chain was a no-op on the resolved tree, so the head is the merge commit itself; Lint & Repo Gates (which carries check:generated) is success on this head.

Every accept-set and public-surface change, re-rendered on this head. Because the 20 non-registry files are byte-identical to 18cbf4e2 and the registry additions are line-identical, each judgment below is carried from 5865889450 on confirmed-unchanged bytes and re-anchored to the head's lines:

  1. Traversal (engine.ts traverseNext) — RIGHT. :10382 exclusive = node.type === 'decision' && !decisionTakesEveryBranch(node), where :239-242 returns config?.mode === 'inclusive'; once a conditioned out-edge holds, every later sibling is passed over unevaluated and recordSkipped (:10331) writes the closed-gate skipped step; anyConditionMet still gates isDefault (:10408-10420); the unconditional bucket and non-decision nodes untouched; declaration order = flow.edges order. Ruling item 1. Scope note unchanged: the flag keys on node type, corpus has zero conditions-list decisions with two conditioned edges under one label, direction matches the ruling.
  2. mode: 'inclusive' — RIGHT. Sequential await inside the loop, never Promise.all; nested-not-interleaved pinned. Ruling item 2.
  3. Registration door (validateDecisionModes) — RIGHT. Every decision in every graph parsed through DecisionConfigSchema.safeParse; refusal on mode-rooted issues only, schema sentence at node 'x' (decision) at config.mode; never armed. Acceptance 5857171841.
  4. os validate — RIGHT. flow-decision-mode-invalid (gating) and flow-decision-inclusive-overlap (advisory) at lint-flow-patterns.ts:242,250, emitted at :927,955, barrel-exported at lint/src/index.ts:844-845. Ruling item 4.
  5. D2 conversion flow-decision-mode-inclusive-explicit — RIGHT. conversions/registry.ts:11869-11873: toMajor: 18, retiredFromLoadPath: true; DECISION_MODE_INCLUSIVE_MIN_CONDITIONED_EDGES = 2 (:12006); wired at :12208 and in step 18's conversionIds. No alias, no window, no inference over CEL; same PR as the semantics. Ruling item 3.
  6. D3 entry 18.flow-decision-edge-branching-first-match.ts + generated block (migrations/registry.ts:10500) — RIGHT. Names the D2 id as a whole word; reason and acceptanceCriteria carry ruling C (BREAKING for stored rows, no rewrite / cutoff / read-path completion, the one-line fix, the --stored listing under decisionModeReview, the house --from 17 sentence). The ruling-retired phrasing ("judgment owed", "rewritten by nothing", "half no command reaches") has 0 hits at the head across packages, content, .changeset, docs.
  7. Replay sites of the flip id — each RIGHT. Authored sources via applyMetaMigrations(stack, 17, 18): replays, pinned both ways. Authoring funnel: refused by retiredFromLoadPath. Engine rehydration seam: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION = ['flow-decision-mode-inclusive-explicit'] (engine.ts:2153-2155) fed as excludeConversionIds beside includeRetired: true (:4123-4124). Artifact door: DEFAULT_FLIPS_NOT_REPLAYED_HERE lists it beside app-hidden-to-unpublished with its reason (artifact-forward-conversion.ts:290,310-312), fed at :361. Stored pass: listing only, through the entry's own apply by id (protocol.ts:16944, stored-migration.ts:288). Enumeration still closed: includeRetired: true outside tests occurs at exactly the three known sites (artifact door :360, engine seam :4123, spec/conversions/stored.ts:104).
  8. Report widening (@objectstack/metadata-protocol) — RIGHT. StoredDecisionModeReview (stored-migration.ts:168, exported at index.ts:162), required decisionModeReview (:211), initialised [] at protocol.ts:16826, filled at :16944; renderer prints the section with the fix; CLI and REST carry the object unchanged, meta.ts untouched (the SDK's meta.migrateStored is deliberately unbound, ruling 2C).
  9. Prose — RIGHT, with the one residue. Module header, mode describe/docblock, flows.mdx, cli.mdx, logic-nodes.ts, the engine.ts traversal comment (now true), regenerated reference mdx — all byte-identical to the reviewed head. Residue at schemaless-node-config.zod.ts:451 unchanged (③).
  10. Pin rewrite — RIGHT. decision-overlapping-edge-conditions.pin.test.ts is the contract pin per its own header, ruling-C pair included; byte-identical. Ruling item 5.
  11. examples/** not rewritten — RIGHT. Chain-only conversion; the four in-tree positives partition.

② Semver level

.changeset/15429-decision-edge-branching-first-match.md is among the 20 byte-identical files, re-read at the head: @objectstack/spec minor · @objectstack/service-automation minor · @objectstack/lint minor · @objectstack/metadata-protocol minor · @objectstack/metadata-core patch; feat(automation)!: title; the adr-0087 registered flow-decision-mode-inclusive-explicit disposition marker in the gate's comment form; Clause-②: yes (the PR body carries the same line); BREAKING banner with the before/after table, the FROM → TO block, and the stored-row BREAKING section naming the shape, the one-line fix and the --stored listing. Check Changeset success on this head (08:15:14Z).

③ Boundary flags

Dev flags, each answered:

  • Landing-lap report 5866729342 — deviations: (1) "No regeneration commit: the chain was a no-op, the head is the merge commit" — verified: 0c4dad2a has parents 18cbf4e2 and dcd3bcea, nothing follows it on the branch, and check:generated is green in CI on this head. One consequence: the merge commit message's closing line ("generated regions are regenerated in the next commit") is stale — commit-message prose, nothing published, non-blocking. open_questions: none.
  • Blocked report 5865931103 — one deviation (the conflicted merge aborted and the worktree removed; no pushed history changed — 18cbf4e2 is this head's first parent, no force-push) and one open_questions entry (resolution A / B / C) — answered B by the seat in 5865957805; ① shows the head executes B exactly and nowhere else.
  • Ruling-C round report 5865679628 — seven deviations, each answered in 5865889450; every file those answers rest on is byte-identical at this head, so they carry. Deviation 7 (origin/main moved after the merge) is overtaken: 0d7ed5a3, ab6fb027, 2f122b6e, 681868ca, df3ba164 and dcd3bcea are all ancestors of this head.
  • Carried note 1 — non-blocking, footing unchanged. packages/spec/src/automation/schemaless-node-config.zod.ts:451 still opens the DecisionConfigSchema docblock with "declared ahead of the engine change that will read it"; the file's blob is identical to 18cbf4e2, the line reaches no generated artifact, and the head's own next section contradicts it. Source-only residue for a later prose pass.
  • Carried note 2 — non-blocking, footing unchanged. The D3 text "nothing else about conditions-list decisions changes" while the traversal flag keys on node type: engine.ts and the D3 entry are byte-identical to the reviewed head; the corpus has zero such nodes; the direction matches the ruling.
  • New note from this round — non-blocking. The doubled "Finally" in step 18's rationale (①(b)): seat-accepted under resolution B, carried to the next PR that touches that string.

origin/main at 7db1332f, two commits past dcd3bcea: 2c310705 (#20403 — refuse a cross-class RLS / sharing-rule field comparison at authoring: packages/lint/src/validate-rls-predicate-enforceability.ts, validate-sharing-rule-enforceability.ts, packages/spec/src/data/filter-cross-field-comparison-class.ts, data/index.ts, api-surface/data.json, export-origins/data.json, cli / driver-sql tests, its changeset) and 7db1332f (#20405 — metadata-form rows: spec/src/data/object.form.ts, security/permission.form.ts, platform-objects generated translations and tests, its changeset). Path intersection with this PR's 22 files: empty. Neither touches flows, decision config, either registry, service-automation, metadata-protocol or metadata-core; this head imports nothing from them and they read nothing this head adds. In meaning they are orthogonal (an authoring-time comparison-class refusal; form rows) to decision traversal. git merge-tree --write-tree origin/main 0c4dad2a → clean tree 4745098a, exit 0, no conflict entries. Nothing in this head depends on those two commits, and nothing conflicts with them in meaning; the next landing lap is a pure merge.

Ruling coverage: 5793803317 items 1–5 and 5863827385's execution parameters and three pins — carried from 5865889450 on unchanged bytes; the retired sentences have 0 hits at the head; docs/adr/** untouched; objectui#10750 proceeds on its own card and nothing in the 22 paths reaches objectui. No governed surface among the 22 paths (Governed Surface Queue Guard success); the claim 5864486967 names this branch (The card this PR closes must claim this branch success).

Escalations: none.

Implemented-by: claude/issue-15429-decision-first-match
Reviewed-by: session_01ARcDurZ5j34RdqsGgc4jgH

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 09:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…bjectstack-ai#20377)

Fixes objectstack-ai#17940

Clause-②: no

## What was wrong

`content/docs/automation/flows.mdx`'s subflow-chain repair paragraph
conflated three distinct outcomes into one block of prose, and its
closing
sentence — "an ancestor is never *stranded*, because resuming it is not
what moves it" — was written for exactly one of them. objectstack-ai#15556 (PR objectstack-ai#17908,
merged `c8a006fc41`) shipped a case the paragraph never described, where
that sentence is false: the child completes, `bubbleToParent` resumes
the
parent, and the parent's own downstream node throws. There the bubble is
exactly what moves the parent, and the parent itself lands on the
engine's
`'stranded'` exit.

## Before

> A child that fails terminally after the pause fails every waiting
ancestor, so
> no run is stranded as resumable-forever. **When the child's failure is
a
> strand** — its resume consumed the pause and a downstream node threw —
each
> ancestor's consumed pause is recorded too, and the repair verb puts
the whole
> chain back in one call: `POST …/runs/{runId}/restore-suspension` on
**any**
> member re-arms every member, deepest first, so the continuation
re-issued on
> the run you named flows back up through the ancestors instead of
completing a
> leaf into a parent that never continues. Re-arming an ancestor is all
the verb
> does — an ancestor is never *stranded*, because resuming it is not
what moves
> it. A cascade from a child that is **not** repairable records no
ancestor
> snapshot, deliberately, so the verb never promises a chain repair it
could not
> finish.

## After (revised per PM review — no self-reference, repair verb named)

> A child that fails terminally after the pause fails every waiting
ancestor, so
> none of them is left stranded as resumable-forever. **When the child's
failure
> is a strand** — its resume consumed the pause and a downstream node
threw —
> each ancestor's consumed pause is recorded too, and the repair verb
puts the
> whole chain back in one call: `POST …/runs/{runId}/restore-suspension`
on
> **any** member re-arms every member, deepest first, so the
continuation
> re-issued on the run you named flows back up through the ancestors
instead of
> completing a leaf into a parent that never continues. Re-arming an
ancestor is
> all the verb does in this case — no ancestor is stranded here, because
> resuming it is not what moves it. A cascade from a child that is
**not**
> repairable records no ancestor snapshot, deliberately, so the verb
never
> promises a chain repair it could not finish.
>
> **A third case is the opposite: the bubble itself is what strands an
> ancestor.** The child completes cleanly, `bubbleToParent` resumes the
parent
> on the child's behalf, and the parent's own downstream node throws.
There the
> bubble — not a resume the caller issued — is exactly what moves the
parent,
> and it is the parent, not the child, that lands on the engine's
`'stranded'`
> exit, terminal. The child's own resume genuinely succeeded, so its
resumer
> (an approvals decision door, a wait timer) is told the resume
succeeded; as
> of objectstack-ai#15556 an approval `decide()` also reports the stranded parent on
> `resumeFailure` (`{ code: 'RESUME_FAILED', runId: '<parent>', status:
> 'stranded', repairable: true }`). Repair it the same way: the same
> `restore-suspension` verb (`restoreConsumedSuspension` underneath),
issued on
> the parent's run id — the `runId` `resumeFailure` names, not the
child's.

## Code measured on `origin/main` `a88a1bb39` (not copied from the card)

- `packages/services/service-automation/src/engine.ts:1737` — the
`SubflowParentStrand` interface (`runId`, `repairable: true`, `error`),
  recorded only on the arm `AutomationResult.status` calls `'stranded'`.
- `packages/services/service-automation/src/engine.ts:7434` —
`bubbleToParent`:
`if (parentRes.status === 'stranded')` records the `SubflowParentStrand`
  under the **child's** own run id.
- `packages/services/service-automation/src/engine.ts:7511` —
  `takeSubflowParentStrand(childRunId)`, the delete-on-read hand-off.
- `packages/plugins/plugin-approvals/src/approval-service.ts:3476` — the
  approvals decision door's `resumeFailure` on a `bubbleStrand`:
`{ code: 'RESUME_FAILED', runId: bubbleStrand.runId, status: 'stranded',
repairable: bubbleStrand.repairable }` — matches the card's claimed
shape.
- `packages/services/service-automation/src/engine.ts:7994` —
  `restoreConsumedSuspension`, the repair verb for the parent strand.
- `packages/runtime/src/domains/automation.ts:2752` — the REST door,
  `POST /:name/runs/:runId/restore-suspension`, routes `parts[2]` (the
  `:runId` path segment) straight into
`automationService.restoreConsumedSuspension(parts[2], …)` — so the same
  `restore-suspension` verb an operator calls in case (a)/(b) is what a
  third-case operator calls too, on the parent's run id (the `runId`
  `resumeFailure` names).

Binding honoured (thread comment `5697194225`): objectstack-ai#17541 owns the naming
of any
new `AutomationResult.status` member. This PR coins none — `'stranded'`
is
the status the engine and the approvals door already use today.

## PM review addendum

PM review verified all code anchors and asked for two prose fixes,
applied
in commit `28c110796`:
1. Dropped the two sentences that referred to the page's own prose
   ("that sentence is scoped to…" / "the claim above does not hold for
   it") and restated the scoping as behaviour.
2. Named the third case's repair verb the same way the other two cases
do
   — the `restore-suspension` REST verb (`restoreConsumedSuspension`
underneath), issued on the parent's run id — instead of only the engine
   method name.

## Gates run (docs-only change, no changeset — `content/docs/**` is not
a
published package surface)

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands content/docs/automation/flows.mdx` derived 40 command(s),
same 40
before and after the revision. All 40 ran green both times (reconciled
with
`--ran`: `40 derived, 40 run, 0 NOT-MEASURED, 0 UNRUN`). One-time
prerequisite builds these gates needed (`@objectstack/formula` +
`@objectstack/lint`, and `@objectstack/client` +
`@objectstack/client-react`)
— neither package's source was touched by this diff. Full command list
and
outputs are in the report comment on objectstack-ai#17940.

Serial neighbour: draft PR objectstack-ai#20344 edits the same file at `:1357` and
below;
this diff's hunk sits at `:1104`–`:1129`, 245+ lines above it — no
overlap.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 28, 2026
Landing lap 2: origin/main 40b315b, 6 commits past dcd3bce. #20350
(`40b315b0`) appended its connector-resilience sentence to step 18's
`rationale`, the one conflict region (conversions/registry.ts and step
18's `conversionIds` merged cleanly). Resolved per the seat's answer B
on #15429 (5865957805, re-applied by 5867190364), and nowhere else:
main's text kept whole (the action-aria sentence, then the
connector-resilience sentence), this branch's sentence appended
verbatim; the one string join at the seam makes main's closing literal
end in a space so the concatenation continues.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b30325bb385e5b4da8e31538ea7931550ec1cd56
Local-runs: none

① Derived judgments

Inputs read in full: card #15429 (body + all 30 comments — rulings 5793803317 and 5863827385, resolution B 5865957805, its re-application 5867190364, the ACCEPT 5866992766, reports 5865679628 / 5865931103 / 5866729342 / 5867993849, the two earlier records 5865889450 / 5866970574), PR #20344 body (Fixes #15429, Clause-②: yes, no longer draft, mergeable: true, mergeable_state: clean, base sha 40b315b0) and its 22-file list, the net diff against main at 40b315b0 (the PR's base and the head's second parent, so two-dot equals three-dot: 22 files, +2157 / −205, path-for-path and count-for-count equal to the file list), and the check-runs on this head: 35 — 33 success · 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in), both named on scripts/pm/check-expected-skips.mjs's roster) · 0 failure · 0 in progress; latest completion 09:57:02Z. Check Changeset, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Flag docs affected by code changes, every Type Check and Test Core shard, both Dogfood gates, Temporal Conformance, and the four PR-shape guards (part-of, claim, single-writer path, same-issue) all success.

The merge resolution, judged first (head = merge commit, parents 0c4dad2a and 40b315b0; no regeneration commit followed — origin/claude/issue-15429-decision-first-match IS b30325bb):

Every accept-set and public-surface change, re-rendered on this head. The 20 non-registry files are byte-identical to 0c4dad2a and the registry additions are line-identical, so each judgment below is carried from 5866970574 on confirmed-unchanged bytes, re-read against the diff at this head and re-anchored to this head's lines:

  1. Traversal (engine.ts traverseNext) — RIGHT. :239 decisionTakesEveryBranch returns config?.mode === 'inclusive'; :10382 exclusive = node.type === 'decision' && !decisionTakesEveryBranch(node); once a conditioned out-edge holds, every later sibling is passed over unevaluated and recordSkipped (:10331, called at :10388) writes the closed-gate skipped step with skippedBy naming gate and edge; anyConditionMet still gates isDefault; the unconditional Promise.all bucket and non-decision nodes untouched; declaration order = flow.edges order (filtered in place; parse and conversions copy-on-write). Ruling item 1. Scope note unchanged: the flag keys on node type, the corpus holds zero conditions-list decisions with two conditioned edges under one label, direction matches the ruling.
  2. mode: 'inclusive' — RIGHT. Sequential await inside the loop, never Promise.all; nested-not-interleaved pinned with the fan-out as positive control. Ruling item 2.
  3. Registration door (validateDecisionModes, engine.ts:9298-9355, called at :4185) — RIGHT. Every decision in every graph (collectFlowGraphs, regions prefixed) parsed through DecisionConfigSchema.safeParse; refusal on mode-rooted issues only, with the schema's own sentence at node 'x' (decision) at config.mode; hard-fail, never armed (getFlow null pinned). Acceptance 5857171841. Judging mode alone is a declared, reasoned scope.
  4. os validate — RIGHT. flow-decision-mode-invalid (lint-flow-patterns.ts:242, severity error, finding IS the schema issue message, hint names registerFlow) and flow-decision-inclusive-overlap (:250, advisory; two or more conditioned non-fault out-edges; one edge plus default excluded), barrel-exported at lint/src/index.ts:844-845, region-reaching, no double report with flow-decision-unconditional-branch. Ruling item 4.
  5. D2 conversion flow-decision-mode-inclusive-explicit — RIGHT. conversions/registry.ts:11944-11945: toMajor: 18, retiredFromLoadPath: true; predicate = decision with no non-empty conditions, no mode key, at least DECISION_MODE_INCLUSIVE_MIN_CONDITIONED_EDGES = 2 (:12081) conditioned non-fault out-edges (string or {dialect, source} envelope; blank = none); regions via FLOW_REGION_SLOTS_BY_TYPE with a depth ceiling; copy-on-write; one notice per write; fixture pair with 2 notices; wired at :12286 and migrations/registry.ts:5523. No alias, no window, no inference over CEL; same PR as the semantics. Ruling item 3.
  6. D3 entry 18.flow-decision-edge-branching-first-match.ts + generated block (migrations/registry.ts:10580) — RIGHT. Names the D2 id as a whole word; reason and acceptanceCriteria carry ruling C (BREAKING for stored rows, no rewrite / cutoff / read-path completion, the one-line fix, the --stored listing under decisionModeReview, the house --from 17 sentence); step 18's rationale carries the sentence (①(c)). The ruling-retired phrasing ("judgment owed", "judgment still owed", "rewritten by nothing", "half no command reaches") has 0 hits at the head across packages, content, .changeset, docs.
  7. Replay sites of the flip id — each RIGHT, enumeration re-closed on the merged tree. Authored sources via applyMetaMigrations(stack, 17, 18): replays, pinned both ways. Authoring funnel normalizeStackInput: refused by retiredFromLoadPath, pinned. Engine rehydration seam: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION = ['flow-decision-mode-inclusive-explicit'] (engine.ts:2153) fed as excludeConversionIds (:4124) beside includeRetired: true (:4123). Artifact door: DEFAULT_FLIPS_NOT_REPLAYED_HERE (artifact-forward-conversion.ts:310-312) lists it beside app-hidden-to-unpublished, fed at :361 beside includeRetired: true (:360); four-leg pin. Stored pass: listing only, through the entry's own apply by id (stored-migration.ts:288, protocol.ts:16944). includeRetired: true outside tests at this head occurs in exactly three source sites — the artifact door :360, the engine seam :4123, and spec/conversions/stored.ts:104 (the item primitive, which flow rows never reach) — the remaining hits are CHANGELOG prose and a types.ts docblock. feat(spec)!: retire the connector resilience family — health (probe + breaker), status and nested webhooks, sixteen keys nothing read (#20273) #20350's merge (connector zod, liveness ledgers, plugin-auth, connector packages) opened no fourth seam.
  8. Report widening (@objectstack/metadata-protocol) — RIGHT. StoredDecisionModeReview (stored-migration.ts:168, exported index.ts:162), required decisionModeReview (:211), initialised [] at protocol.ts:16826, filled at :16944-16945 read off the stored body before the canonicalizer and with no engine; formatStoredMigrationReport prints the section with the fix only when non-empty; describeScope widened to a Pick; storedMigrationClean unmoved; CLI and REST carry the object unchanged, meta.ts untouched (SDK meta.migrateStored deliberately unbound, ruling 2C).
  9. Prose — RIGHT, with the one residue. Module header, mode describe/docblock ("Where mode is honoured"), flows.mdx (exclusive/inclusive paragraphs, the upgrade callout, the stored-flow BREAKING callout), cli.mdx --stored paragraph, logic-nodes.ts, the engine.ts traversal comment (now true), regenerated reference mdx — all byte-identical to the reviewed head. Residue at schemaless-node-config.zod.ts:451 ("declared ahead of the engine change that will read it") confirmed present at this head, source-only (③).
  10. Pin rewrite — RIGHT. decision-overlapping-edge-conditions.pin.test.ts is the contract pin per its own header (22 tests, ruling-C pair included); byte-identical. Ruling item 5.
  11. examples/** not rewritten — RIGHT. Chain-only conversion; the four in-tree positives partition.
  12. What the merge brought and does not publish here. feat(spec)!: retire the connector resilience family — health (probe + breaker), status and nested webhooks, sixteen keys nothing read (#20273) #20350's retirement of connector.health / status / nested webhooks is orthogonal to flows and decisions; nothing in this PR's 22 files reads those keys, and its own added lines reference none of feat(spec)!: retire the connector resilience family — health (probe + breaker), status and nested webhooks, sixteen keys nothing read (#20273) #20350's symbols. Judged: no new accept-set change enters through the merge.

② Semver level

.changeset/15429-decision-edge-branching-first-match.md is among the 20 byte-identical files, re-read at the head: @objectstack/spec minor · @objectstack/service-automation minor · @objectstack/lint minor · @objectstack/metadata-protocol minor · @objectstack/metadata-core patch; feat(automation)!: title; the adr-0087 registered flow-decision-mode-inclusive-explicit disposition marker in the gate's comment form; Clause-②: yes (the PR body carries the same line, alone); BREAKING banner with the before/after table, the FROM → TO block, and the stored-row BREAKING section naming the shape, the one-line fix and the --stored listing. Check Changeset success on this head.

  • spec minor — right. The D2 conversion and D3 entry enter the protocol-18 chain and the describe/docblock text; nothing published narrows. Premise re-checked at this head: PROTOCOL_VERSION is still 17.0.0, and .changeset/19867-decision-config-mode.md and .changeset/20168-decision-mode-beside-conditions-refused.md are still unconsumed in the tree, so mode ships with the traversal that reads it and the conversion that writes it.
  • service-automation minor with BREAKING banner — right. Run-time semantics flip on a shipped node type plus a new registration refusal, in the house launch-window form (check-changeset-no-major, green on the head).
  • lint minor — right. Two new exported rule ids; the gating one refuses only a mode that never shipped.
  • metadata-protocol minor — right. A published report type gains a required field and a type is exported; no external constructor (workspace type-check green).
  • metadata-core patch — right. One array entry refusing an id new in the same release.
  • Clause-②: yes — right. New keys/entries on published payloads (D2/D3 entries, the two rule ids, decisionModeReview); nothing narrows; the level axis is met by spec minor.
  • The merge added nothing this PR publishes: feat(spec)!: retire the connector resilience family — health (probe + breaker), status and nested webhooks, sixteen keys nothing read (#20273) #20350's .changeset/20273-connector-resilience-keys-retired.md and the other three new main changesets are independent files already on main.

③ Boundary flags

Dev flags, each answered:

  • Landing-lap 2 report 5867993849 — deviations: (1) "No regeneration commit: the chain was a no-op, the head is the merge commit" — verified: b30325bb has parents 0c4dad2a and 40b315b0, nothing follows it on the branch, check:generated is green in CI on this head. Its numstat (identical before/after), id_presence (each of the three ids once per registry; main's whole registries present) and resolved_hunks claims each re-derived above (①(a)–(d)) and hold. open_questions: none.
  • Seat note 5867190364 and resolution B 5865957805 — the head executes B exactly: main's text kept whole (the action-aria sentence, then feat(spec)!: retire the connector resilience family — health (probe + breaker), status and nested webhooks, sixteen keys nothing read (#20273) #20350's connector-resilience sentence), this PR's sentence appended verbatim, one separator at the seam, nowhere else hand-edited. The merge commit's own message describes this lap accurately.
  • Earlier laps' flags (5866729342's one deviation, 5865931103's aborted-merge deviation and its A/B/C question answered B, 5865679628's seven deviations) — carried from 5866970574; every file those answers rest on is byte-identical at this head. One footnote moves: the stale line "generated regions are regenerated in the next commit" lives in 0c4dad2a's commit message, now this head's first parent; commit prose only, nothing published, non-blocking.
  • Carried note 1 — non-blocking, footing unchanged. packages/spec/src/automation/schemaless-node-config.zod.ts:451 still opens the DecisionConfigSchema docblock with "declared ahead of the engine change that will read it"; the blob is identical to 18cbf4e2 / 0c4dad2a, the line reaches no generated artifact, and the head's own next section contradicts it. Source-only residue for a later prose pass.
  • Carried note 2 — non-blocking, footing unchanged. The D3 text "nothing else about conditions-list decisions changes" while the traversal flag keys on node type: engine.ts and the D3 entry are byte-identical; the corpus has zero such nodes; the direction matches the ruling.
  • Carried note 3 — non-blocking, footing eased. The doubled "Finally" in step 18's rationale: after this merge the two openers are separated by feat(spec)!: retire the connector resilience family — health (probe + breaker), status and nested webhooks, sixteen keys nothing read (#20273) #20350's "It also retires…" sentence, so they no longer read consecutively; two "Finally" sentences remain in one paragraph, seat-accepted under resolution B, carried to the next PR that touches that string. Rewording main's opener would have breached B.
  • Carried note 4 — non-blocking. Stale merge-commit prose: see the earlier-laps bullet; b30325bb's message is accurate.
  • New notes from this round: none.

origin/main at 50e273fd (#20407, service-analytics), one commit past 40b315b0: it touches .changeset/20381-adhoc-cube-request-scope.md, packages/services/service-analytics/src/analytics-service.ts, its __tests__/adhoc-query-request-scope.test.ts and packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts — the ad-hoc query and sql doors' cube scope and inferred-cube publication. Path intersection with this PR's 22 files: empty. This PR's added lines mention none of its symbols (0 hits for service-analytics, AnalyticsService, requestScope, CubeScope, ensureCube, publishInferredCube in the diff's + lines; the "analytics" tokens present in the big registries and protocol.ts are pre-existing text with identical counts on 40b315b0). In meaning it is orthogonal (analytics request scoping) to decision traversal, migrations, lint and the stored-migration report; neither side imports or reads the other. git merge-tree --write-tree origin/main b30325bb → clean tree 033f0920, exit 0, no conflict entries; GitHub reports mergeable_state: clean. Nothing in this head depends on 50e273fd, and nothing conflicts with it in meaning; the queue can take this head as it stands.

Ruling coverage: 5793803317 items 1–5 and 5863827385's execution parameters and three pins — carried from 5866970574 on unchanged bytes and re-read at this head (stored no-mode overlapping decision runs first-match; --stored lists it and changes nothing; a --from 17 source carries explicit mode: 'inclusive' and takes every branch); the retired sentences have 0 hits; docs/adr/** untouched; objectui#10750 proceeds on its own card and nothing in the 22 paths reaches objectui. No governed surface among the 22 paths (Governed Surface Queue Guard success); claim 5864486967 names this branch (The card this PR closes must claim this branch success); no other open PR claims the issue or a single-writer path (both guards success).

Escalations: none.

Implemented-by: claude/issue-15429-decision-first-match
Reviewed-by: session_01ARcDurZ5j34RdqsGgc4jgH

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 0283cb9 Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15429-decision-first-match branch September 28, 2026 10:48
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…retiredAfter; the artifact door opens its window per entry (objectstack-ai#20390) (objectstack-ai#20435)

Fixes objectstack-ai#20390

Clause-②: yes

Implements ruling `5865890672` (batch objectstack-ai#235 item 1, letter **A**,
maintainer 「同意 A」; maintainer record `5865873150`, route `5866178043`):
every retired entry in the ADR-0087 conversion registry carries a
REQUIRED `retiredAfter`, and the artifact forward-conversion window
decides per entry. It is one vertical PR across `packages/spec`,
`packages/metadata-core` and the artifact door in `packages/metadata`.

## Spec half

- **`MetadataConversion` is a live-or-retired union**
(`packages/spec/src/conversions/types.ts`). An entry with
`retiredFromLoadPath: true` must also carry `retiredAfter`, typed as a
stable `x.y.z` template-literal string; a live entry carries neither.
tsc refuses an unstamped retirement (the reverse verification is below).
The type moves from an interface to a type alias, so `gen:api-surface`
and `gen:export-origins` each rewrite one row: `MetadataConversion
(interface)` becomes `MetadataConversion (type)`.
- **Backfill, from the published tarballs.** Each published entry's
value is the stable release just before the first tarball that carries
it retired. Each entry in no published tarball carries the current
`package.json` label, `17.4.0`.
- **Census test.** `src/conversions/retired-after.census.json` holds raw
facts per stable release since the registry first shipped (14.8.0
through 17.4.0): the tarball integrity and the ids its `ALL_CONVERSIONS`
marks retired. `src/conversions/retired-after.census.test.ts` pins every
entry's value against it, offline, in the `local` tier. It pins that
every entry absent from the last published tarball carries the label,
and that no value is malformed or above the label.
`scripts/build-retired-after-census.ts` re-derives the census from
registry.npmjs.org. It checks each tarball's integrity, imports each
release's `dist/index.mjs`, and writes the census, or compares it with
`--check`.

## metadata-core half

`applyArtifactForwardConversions` replays entry E when the artifact's
floor is below the runtime label OR at or below `E.retiredAfter`.
`DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. Its membership is
unchanged; `flow-decision-mode-inclusive-explicit` came in with the
merge of objectstack-ai#20344. When the floor is at or above the label, only the
entries the floor predates are replayed. The rest reach the strict parse
and their tombstones through the existing `excludeConversionIds` seam,
computed per entry from the registry. There is no second table.
`ArtifactForwardConversionVerdict` gains `'converted-retired-after'` for
that case. `ArtifactForwardConversionResult` gains `replayedRetirements`
(element type `ArtifactReplayedRetirement`): under that verdict, each
retirement this runtime enforces past the artifact's floor, with its
`retiredAfter`; it is empty for every other verdict. The module
docblock's two policy sentences still hold: "a key retired at version V
stays a loud refusal for anything authored at ≥ V" (the
floor-at-or-above-label bullet), and "Not a second conversion table".

## The door's consumer arm (`packages/metadata/src/plugin.ts`)

The verdict has one in-tree consumer that branches on it, and the new
arm is added there.

- **Which verdicts open the window** is now one total table,
`FORWARD_WINDOW_OPENED` (a readonly `Record` keyed by every
`ArtifactForwardConversionVerdict` member, valued `boolean`), with
`'converted-retired-after'` on the open side.
`_warnUnboundFormPredicateRoots` (the objectstack-ai#12915 scope-C notice) returns on
`!FORWARD_WINDOW_OPENED[result.verdict]`. That makes its docblock
sentence true again: the notice is "read off that pass's own verdict
rather than recomputed, so the two can never disagree", and it no longer
depends on the label. On `main` today, a 17.4.0-built artifact with a
bare-root form predicate is announced now, not once the label reaches
17.5.0.
- **Why the table, not the inverted guard.** The two forms the order
offered have opposite defaults for a verdict that does not exist yet.
Adding the arm to the old hand-written guard defaults a future verdict
to "closed", which is how this defect arose. Inverting the guard (return
only on `'authored-current'` / `'runtime-version-unknown'`) defaults it
to "open", and it would also admit `'not-an-object'`. A total `Record`
over the verdict union has no default: a new member is a compile error
until someone places it. This is the "add the arm" route, spelled so
that tsc forces the next decision. Reverse-verified below.
- **The warn lines under the new verdict** no longer say the artifact
"predates this runtime's spec" beside a runtime version equal to its
floor. The conversion summary names the retirement this runtime enforces
past the artifact's floor, with the release that last accepted the shape
(from `replayedRetirements`). It then says the artifact converts again
on every boot until it is rebuilt with tooling from a release that ships
the retirement. The objectstack-ai#12915 notice opens with the same verdict-aware
clause. Every other verdict keeps its existing wording.
- `plugin-unbound-form-predicate-roots.test.ts`'s "current surface"
silence pin had derived that surface as a caret range on the installed
label. That spelling is itself the label-dependence this change removes:
on `main` it names an artifact built BY the last release. It now derives
the first `x.y.z` past both the label and every `retiredAfter`.

## The four pins

| Pin | Where | Asserts |
|:--|:--|:--|
| (1) a 17.4.0-CLI-built artifact with dashboard charts and page
`assignedProfiles` boots on `main` and logs the notices |
`packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts`,
on a REAL fixture: `dist/objectstack.json` built verbatim by the
published `@objectstack/cli` 17.4.0 | the dashboard and page register
with `chartConfig.type`/`xAxis`/`yAxis` and `assignedProfiles` converted
away; one warn line each for
`dashboard-widget-chart-config-structure-removed` (3 sites) and
`page-assigned-profiles-removed` (1 site) |
| (2) newly authored sources using the retired keys are still refused
loudly | same file | `defineStack` refuses with `code:
'STACK_SCHEMA_INVALID'`, `status: 422`, and one issue per retired site
(4 paths) |
| (3) floor exactly 17.5.0 on a 17.5.0-labelled runtime is refused, not
converted |
`packages/metadata-core/src/artifact-forward-conversion.test.ts` |
verdict `authored-current`, zero notices, and the strict parse refuses
the same 4 paths |
| (4) unreleased `main` (label 17.4.0), artifact at the last release
(`^17.4.0`) | same file | verdict `converted-retired-after`, notices by
id and path, and the strict parse passes |

Beside pin (1), **the objectstack-ai#12915 pin**
(`plugin-artifact-forward-conversion-retired-after.test.ts`, "announces
a bare-root form predicate once"): the `^17.4.0` fixture with one
bare-root form predicate (`stage == "won"`) on the 17.4.0 runtime logs
the unbound-root line exactly once, including across a second ingestion.
It is red under the old guard and green now (below).

Three companions sit beside the pins. After the release (label 17.5.0)
the same artifact converts through the label half, with
`replayedRetirements` empty. A 17.2.0 retirement still meets its
tombstone inside the open per-entry window.
`flow-decision-mode-inclusive-explicit` stays refused inside its own
per-entry window. Pin (4) also asserts `replayedRetirements`: both
retirements at `17.4.0`, and never the default flip.

## Census (re-derived on this tree, npm `latest` = `17.4.0`, label =
`17.4.0`)

94 retired entries: **73 published** and **21 unpublished**. The ruling
counted 91 retired with 18 unpublished at `df3ba164`. Three unpublished
entries landed since then: `action-aria-removed`,
`connector-resilience-keys-removed` (objectstack-ai#20350) and
`flow-decision-mode-inclusive-explicit` (objectstack-ai#20344, merged into this
branch).

| first published retirement | entries | `retiredAfter` |
|:--|--:|:--|
| 15.1.0 | 5 | 15.0.0 |
| 17.0.0 | 45 | 16.1.0 |
| 17.1.0 | 5 | 17.0.0 |
| 17.2.0 | 2 | 17.1.0 |
| 17.3.0 | 8 | 17.2.0 |
| 17.4.0 | 8 | 17.3.0 |
| none (unpublished) | 21 | 17.4.0 |

The ruling's census bucket of 50 entries "first retired in 17.0.0" is 45
+ 5. The engine seat's census started at the 17.0.0 tarball. Those 5
entries (`object-compactLayout-to-highlightFields`,
`stack-roles-to-positions`, `owd-legacy-read-aliases`,
`sharing-recipient-role-to-position`,
`book-audience-profile-to-permission-set`) are already retired in the
15.1.0, 15.1.1, 16.0.0 and 16.1.0 tarballs, so the ruling's own
principle gives them `15.0.0`.

## Verification (final HEAD `2c537b7e`)

- Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` gave 90 commands at `2c537b7e` (16 files,
+1667/−72), and all 90 exit 0 on that head. The `--ran` reconciliation
(each line carrying its exit code) reads: "90 derived, 90 run, 0
NOT-MEASURED, 0 UNRUN". The full package closure was rebuilt first
(turbo 71/71).
- `@objectstack/spec` `test` (`--project local`): Test Files 565 passed
(565), Tests 16645 passed, 1 todo. `test:repo` (`--project repo`, run in
two halves of 18 files each to fit the foreground cap): 18 files / 460
tests and 18 files / 195 tests, together Test Files 36 passed (36),
Tests 655 passed.
- `@objectstack/metadata-core` `test`: Test Files 16 passed (16), Tests
295 passed (295). `typecheck` exit 0.
- `@objectstack/metadata` `test`: Test Files 55 passed (55), Tests 826
passed (826). `typecheck` exit 0.
- eslint `--no-inline-config --format json` on the 10 changed source
files: 10 files linted, 0 errors, 0 warnings. `eslint.config.mjs` never
enables type-aware linting, so this diff cannot move the verdict on any
untouched file.
- Main was merged three times, all through
`scripts/pm/os-regen-merge.sh`. None of this round's incoming commits
touch `packages/spec/src/conversions`, `packages/metadata-core` or
`packages/metadata`, and none adds a retired entry: every one of the 94
carries `retiredAfter`.

## Ablation and reverse verification (from committed state, through
`scripts/ablation-replace.mjs`)

- **Guard ablation (this round).** In `plugin.ts`, `if
(!FORWARD_WINDOW_OPENED[result.verdict]) return;` was put back to the
old guard, `if (result.verdict !== 'converted-forward' && result.verdict
!== 'converted-undeclared') return;`, with a marker comment. On-disk
count: marker 1, new guard 0. Across the three door suites (21 tests),
exactly one went red, the objectstack-ai#12915 pin ("announces a bare-root form
predicate once"). The rest stayed green, including the updated
current-surface silence pin. Restore: blob `8f43972c` equals HEAD, `git
diff HEAD` is empty, `git status --porcelain` has 0 lines, and all 21
tests pass again. The suites import `plugin.ts` from source, so no build
sits between the mutation and the run.
- **tsc forces the next verdict decision.** With the
`'converted-retired-after': true` row removed from
`FORWARD_WINDOW_OPENED`, `tsc --noEmit` in `packages/metadata` exits 2
with `error TS2741: Property '"converted-retired-after"' is missing`.
Restored to the HEAD blob.
- **Window ablation (round 0, at `87da6b88`).** The per-entry branch was
replaced with the old label-only verdict, and `metadata-core` was
rebuilt, with the marker present in 2 built files. Pin (4), pin (1) boot
and pin (1) notices went red, along with both per-entry companions. Pins
(2) and (3) stayed green. The restore was proven (blob equals HEAD, 0
porcelain lines, and the marker absent from the rebuilt dist).
- **tsc refuses an unstamped retirement.** With `retiredAfter` removed
from `page-assigned-profiles-removed`, spec `tsc --noEmit` exits 2 with
exactly one `error TS2322`.
- **The census test fails when it should.** A published entry stamped
low reds the PUBLISHED test, and an unpublished entry stamped low reds
the UNPUBLISHED test. `build-retired-after-census.ts --check` passes
against npm (11 releases), and exits 1 on a tampered census.

## Deviations from the ruling text, and why

1. **The rule for unpublished entries has one tolerance.** While the
label is AHEAD of the census's last release, an unpublished entry may
carry any version from that release up to the label. Taken literally
("carries the current label"), the rule turns the Version Packages PR
red. That PR bumps the label to 17.5.0 before 17.5.0 is published, while
the 17.5.0 entries correctly carry 17.4.0. The tolerance closes again
once the census records the new tarball. The seat confirmed this reading
(`5869635456`). The refresh is now a written step of the GA release
flow: `docs/releases-maintenance.md`, under "Cutting a GA release — the
Version Packages PR flow", says to run
`scripts/build-retired-after-census.ts` after a stable
`@objectstack/spec` publish and commit the refreshed census. The seat
answered the refresh question with A; no workflow and no gate are added.
2. **The network half is a script, not a repo-tier test** (accepted by
the seat, `5869635456`). `vitest.repo-tests.json` is held equal to the
set of tests that read outside the package
(`check:cross-package-test-inputs`), so a network-only test cannot be
listed there. Reading the tarballs means downloading every stable
release since 14.8.0 (about 11 tarballs, over 250 MB), so no per-run
suite does it. So CI pins the committed census offline, and
`scripts/build-retired-after-census.ts` re-derives it. The script
refuses loudly when offline; it never skips. It is not a `package.json`
script and not wired into CI, so no gate is added.
3. **Stable releases only.** The census and the rule skip `-rc`
versions: a caret floor never names a prerelease, and the door compares
`x.y.z` triples.
4. **Counts.** See the Census section: 73 published, 21 unpublished, and
a 15.1.0 bucket the ruling's counts did not have.

## Acceptance notes

- `packages/metadata` now carries a `patch` changeset entry for the door
change. It changes no public API; the objectstack-ai#12915 notice and the conversion
summary wording follow the per-entry window.
- `field-required-notnull-explicit` appears retired in the 17.0.0
through 17.3.0 tarballs and is gone from 17.4.0 and `main`, withdrawn by
objectstack-ai#16693. The census test ignores ids not on `main`.
- The seat files two follow-ups at landing, as governed surfaces outside
this PR (per `5869635456`): ADR-0087's objectstack-ai#12772 addendum sentence that a
floor at or above the runtime "replays nothing", and the retirement kit
in `.claude/skills/spec-property-retirement/SKILL.md`.
- Once this lands, any open PR that adds a retired entry fails typecheck
until it stamps `retiredAfter`. That is the designed loud direction.
- `main` narrowed `manifest.id` (underscores refused, objectstack-ai#17534). So a
17.4.0-built artifact whose id has an underscore is refused whatever
this window does. The pin fixture uses a reverse-domain id for that
reason.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…ai#20623)

## What this is

The release-time half of the 17.5.0 release notes. The page
`content/docs/releases/v17/17-5.mdx` landed before the cut (objectstack-ai#20396) with
a `RELEASE-TIME TODO` comment listing four edits to make once 17.5.0 was
on npm. 17.5.0 was published on 2026-09-29 (`@objectstack/cli@17.5.0` at
07:58Z, the last package, `@objectstack/spec`, at 08:09Z). This PR makes
those edits, deletes both TODO comments, and updates
`content/docs/releases/v17/index.mdx`.

Docs-only: two files under `content/docs/releases/`, which is
release-owned, so this is the dedicated docs-only PR `AGENTS.md`
sanctions for that tree. It publishes nothing from any package, hence
`skip-changeset`.

## What changed

**`17-5.mdx`**

- **Publish date.** "What's new" now opens: 17.5.0 was published to the
`latest` tag on 2026-09-29, 20 days after 17.4.0.
- **Count.** The draft said it was compiled from "868 changesets pending
on `main` at `ab6fb027`". The version commit `8c87d26a` (objectstack-ai#17076)
actually consumed **958** changesets (the `.changeset/*.md` files it
deletes, README excluded). The page now states 958 as its measure and
cross-checks it against the CHANGELOGs: the 69 package `CHANGELOG.md`
files that carry a 17.5.0 section at `8c87d26a` list **1,372 per-package
entries** (703 minor, 669 patch, 0 major) in 56 of those files, and
those entries de-duplicate to exactly the same 958.
- **The 90 changesets the draft never read**, the ones consumed by
`8c87d26a` but not pending at `ab6fb027`, were each read in full and
folded in:
- **Breaking changes & migration: 45.** Two new subsections: *Written
values are held to the field's declared type* (date and datetime ISO
spellings on a real day, the year range 0001–9999, the numeric string
grammar, `precision`, `progress` bounds, `/import` thousands commas,
with a Migration table) and *An edge-branched decision takes its first
matching branch* (objectstack-ai#20344, with the stored-row caveat). The rest joined
existing subsections: RLS cross-class comparisons; org-less grants; cube
`public`; number comparands, `having` placeholders and double
accumulation; flow node config, `connector_action`, `api` flow secrets
and the connector resilience keys; list-view `tabs`, action `aria` and
view round-trip keys; `/diff` `/history` `/audit` as authoring doors and
OpenAPI `info`; remote Turso and unbuildable indexes; the one stack
authoring shape and new lint positions; QA `requires`, narrowed
published types and `retiredAfter`.
- **New capabilities: 11.** Studio form rows for 27 structured keys, the
staged `$empty` operator, the new `ComponentPropsMap` rows, and email
verification under `open`.
- **Notable fixes: 15.** Dispatcher-only hosts, `/diff` default range,
plain-text email faces, auth-settings sibling isolation, SQLite
`reclaimSpace()`, zh-CN/ja-JP/es-ES object labels, aggregate `search`,
and the OSV sweep.
- **New in Console: 2.** The fourth objectui pin move and the `trash-2`
→ `trash` icon.
- **Judged too minor to surface: 17.** Each is text only, with no
behaviour change an app or operator can reach: describe, docblock and
comment rewrites, `os migrate meta` guidance text, liveness-ledger data
and layout, a form row's declared language, a test-only import change in
`plugin-dev`, and the successor `Link` header of the deprecated
`?layers=true` flag on the environment-scoped mount.
- **Highlights** gain three bullets drawn from the above (decision
first-match, written values, the stack authoring shape). The "running
deployment" warning list gains five lines.
- Every breaking entry that needs an operator action has an
upgrade-checklist line, marked *Not exercised* unless the HotCRM upgrade
below exercised it.
- **Console.** Four pin moves now, not three: `f8a9d0fb0596 →
dd3f7e1be356` (`3cf6449`, objectstack-ai#20436) carries 325 releasing objectui
changesets, 41 of them declared breaking upstream. The Highlights,
"What's new" and Console sections all say four.
- **Dependencies.** `nodemailer` is `^10.0.2`, not `^9.1.1`. That is a
major bump for GHSA-6vj9-mwq6-2f5v, which has no 9.x fix. The line also
carries the operator-visible note from objectstack-ai#20564's changeset: from
nodemailer 10.0.12, `requireTLS` wins over `ignoreTLS`, so a
`transportOptions: { ignoreTLS: true }` override on a port other than
465 now upgrades to STARTTLS or fails the send, and `secure: false` is
the way to connect in the clear.
- **New subsection "Also shipped in 17.5.0 — not in its CHANGELOG".**
The publish ran from `main` at `0f6dcac5` (Release run 36536081716), 8
first-parent commits after the version commit, so the npm packages also
contain `6e3aa75e a093ce3 92fe081 3a89d45 7001918 c96beb2 ba4648d
0f6dcac`. Their changesets are still unconsumed in `.changeset/`. The
subsection gives one line per commit and says they will be listed again
in 17.6.0's CHANGELOG and that the cause is tracked in objectstack-ai#20613. The
breaking `92fe0814` (objectstack-ai#20458, cube member inner `name` retired) gets a
Migration note taken from its own changeset and a checklist entry, and
the checklist preface says where that note lives.

**`v17/index.mdx`** (following the 17.4.0 curation precedent `b11bfb9a`)

- frontmatter description: "17.0.0 through 17.5.0";
- status blockquote: 17.5.0 is released and current, published
2026-09-29, taking over from 17.4.0; a plain install resolves 17.5.0;
the minors warning names 17.5.0;
- a "17.5.0 stays in that register" paragraph drawn from the page's
Highlights, linking `#breaking-changes--migration-in-1750` and
`#upgrade-checklist`;
- the per-release list marks 17.5.0 current and 17.4.0 no longer
current;
- the checklist callout records that 17.4.0 → 17.5.0 has been exercised
only in part (seven lines, on HotCRM), and the per-release checklist
links lead with 17.5.0.

## Findings from a HotCRM 17.4.0 → 17.5.0 upgrade

These were folded in at the coordinator's request; the parent session
verified them.

- **Decision-mode flip** (objectstack-ai#20344): now a 17.4.0 → 17.5.0 table, a
standing warning that flows stored in `sys_metadata` take the new
meaning without being rewritten, and a checklist line. The line says to
review each `mode: 'inclusive'` that `os migrate meta --from 17` offers,
deleting it where the conditions partition, because applied blindly it
draws `flow-decision-inclusive-overlap`. It then says to review the
`--stored` list.
- **`specVersion` / `engines.protocol`**: the checklist now says what an
app does after a 17.x minor, from the code. `PROTOCOL_VERSION` is still
`17.0.0`, and the handshake compares only the major, so
`engines.protocol: '^17'` stays, a `^17.0.0` `specVersion` admits
17.5.0, and a `^18` range is refused `OS_PROTOCOL_INCOMPATIBLE`.
"Protocol 18" is the migration registry's next major; the 17.5.0 schemas
already refuse its shapes, which is why `os migrate meta --from 17` runs
to 18. The Breaking-changes intro carries the same sentence.
- **Seven checklist lines** are marked *Exercised on HotCRM (a 17.4.0
app with a 17.4.0-created SQLite DB), 2026-09-29* with the observed
result: `os doctor` scheduled-work reading, the `account-issuer`
pre-flight, `os migrate meta --from 17` (41 refusals in 874 lines, 240
of them generic protocol-18 notices, so filter the output), the decision
review with `--stored` (0 rows), `page.assignedProfiles`, lookup screen
field `reference`, and `chartConfig` (34 sites). Every other line stays
*Not exercised*, and the preface and the v17 index callout say the hop
was exercised only in part.

## Citations

Every added `#N` was resolved on the board: 144 candidate numbers from
the 90 commits and the 8 post-version commits, all resolving, and objectstack-ai#20613
is open. SHAs are 7-character short SHAs, and each was verified to
resolve unambiguously.

## Gates run (workspace installed)

The full sweep ran on `2b3b323b`. The head `664854a4` changes one phrase
in one checklist line, and on it the MDX parse, `check:doc-anchors`,
`check:role-word`, `check:issue-citations --base origin/main`, the
audit-scope gate and the release-page gates were re-run, all green.

Named in the task, all exit 0:

- `pnpm check:doc-anchors`: 391 internal fragment links, all resolve.
- `node scripts/check-issue-citations.mjs --base origin/main`: 119
citations judged (104 resolve as pull requests, 1 as an issue, 14
cross-repo `objectui#N` unjudged); every added citation resolves.
- `pnpm check:role-word`: no new occurrences.
- `node scripts/docs-audit/check-audit-scope.mjs`: in sync, and
release-owned pages are review-only.
- `check-release-page-status`, `check-release-section-coverage` (plain
and `--strict`) and `check-release-notes`: all OK.
- MDX parse: both pages compile with `@mdx-js/mdx` 3 + `remark-gfm`, and
all 7 tables on `17-5.mdx` parse with no ragged rows.

Derived with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands`: 47 commands, **all 47 exit 0**.
The first sweep hit 5 prerequisite refusals (exit 3, or `check:docs` on
the missing gitignored `json-schema` tree) from unbuilt
`@objectstack/spec`, `@objectstack/formula`, `@objectstack/lint` and
`@objectstack/client-react`. None was a finding. Those packages were
built and the whole list was re-run. Among the 47:
`check:doc-authoring`, `check:docs-single-h1`, `check:docs-redirects`,
`check:corpus-claim-drift`, `check:docs-transcript-drift`,
`@objectstack/spec check:docs` / `check:skill-examples` /
`check:liveness`, `@objectstack/lint check:doc-formula-expressions` /
`check:doc-security-posture`, `check-doc-frontmatter`,
`check-docs-section-name`, `check-section-landing-index` and
`check:nul-bytes`.

The diff was also re-read by hand; the fixes from that pass are the
second commit (`da443bdb`).

## Not in this PR

`content/docs/upgrading.mdx`'s per-release table still reads "v17.4.0 —
⛔ checklist not written; machine-draft notes only" and has no 17.5.0
row. It is a hand-written tree outside `content/docs/releases/`, so it
is left for a separate change.


---
_Generated by [Claude
Code](https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

3 participants