feat(automation)!: edge-branched decision is exclusive; mode: 'inclusive' takes every branch (#15429) - #20344
Conversation
…ive'` takes every branch A `decision` with no `config.conditions` now takes the FIRST conditioned out-edge whose condition holds, in declaration order (BPMN exclusive gateway); the passed-over siblings record a `skipped` step. `mode: 'inclusive'` takes every one, sequentially. `isDefault` is unchanged. - registration parses `DecisionConfigSchema` and refuses an invalid `mode` (value outside the pair, or beside a non-empty `conditions` list) with the schema's sentence; `os validate` reports the same as `flow-decision-mode-invalid`, plus the advisory `flow-decision-inclusive-overlap`. - ADR-0087 D2 `flow-decision-mode-inclusive-explicit` (retired from the load path, refused by the flow rehydration seam by id) writes `mode: 'inclusive'` onto decisions with >= 2 conditioned out-edges for `os migrate meta --from 17`; D3 entry `flow-decision-edge-branching-first-match` carries the judgment. - the status-quo pin is rewritten as the contract pin; docs describe both modes. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…nswer Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
Both registries gained an entry on each side on the same lines (`view-overlay-owner-hidden-removed` from #20286, this branch's `flow-decision-mode-inclusive-explicit`); both kept, landing order. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
…anchor the retirement-jurisdiction citation to the docblock that decided it - packages/lint/src/index.ts: FLOW_DECISION_MODE_INVALID and FLOW_DECISION_INCLUSIVE_OVERLAP join the flow-pattern export block (rule-id-barrel-exports pin). - conversions/registry.ts: the retiredFromLoadPath jurisdiction is cited from MetadataConversion's docblock and ADR-0087's 2026-07-31 addendum, not from a tracker number that no longer resolves. - schemaless-node-config.zod.ts: the mode JSDoc no longer spells an omitted-means-every sentence the empty-state gate has to classify. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
…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>
Contract reviewServed-tier: ① Derived judgmentsInputs read in full: card #15429 (body + all 24 comments, rulings
② Semver levelChangeset
③ Boundary flagsDev flags — newest
Ruling coverage:
Escalations: none. Flagged for the landing lap, non-blocking: the one-line docblock residue at Implemented-by: 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>
Contract reviewServed-tier: ① Derived judgmentsInputs read in full: card #15429 (body + all 27 comments — rulings The merge resolution, judged first (head = merge commit, parents
Every accept-set and public-surface change, re-rendered on this head. Because the 20 non-registry files are byte-identical to
② Semver level
③ Boundary flagsDev flags, each answered:
Ruling coverage: Escalations: none. Implemented-by: VERDICT: PASS |
…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>
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>
Contract reviewServed-tier: ① Derived judgmentsInputs read in full: card #15429 (body + all 30 comments — rulings The merge resolution, judged first (head = merge commit, parents
Every accept-set and public-surface change, re-rendered on this head. The 20 non-registry files are byte-identical to
② Semver level
③ Boundary flagsDev flags, each answered:
Ruling coverage: Escalations: none. Implemented-by: VERDICT: PASS |
…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>
…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>
Fixes #15429
Clause-②: yes
Carries the maintainer's ruling
5793803317(「跟主流对齐」, 2026-09-23) as ONE change, routed todomain:specby5860007474and dispatched by thedomain:specseat 4 PM (session_01CiCTczDo7tGhafXjf61dUJ, claim5860277199). Branch base10ea9eb2e;origin/main(a78f731ad) merged throughos-regen-merge.sh; every reading below is at heade92edee5bunless it says otherwise; the patch commit27a1a4598(the seat's answer A in5861311629) and the ruling C round (5863827385, claim5864486967, head18cbf4e2) carry their own readings where stated. (Body revised by the seat at 2026-09-28T01:34Z from the patch-round report5861745226.)Coordination card: objectstack-ai/objectui#10750 (the designer offers
mode). Nothing is written in objectui here.What changes
AutomationEngine.traverseNext: on adecisionnode the conditioned out-edges are evaluated in the order the flow'sedgesarray declares them and the FIRST one whose condition holds is the branch; its later siblings are not evaluated and record the sameskippedstep a closed gate does (skippedBynames the gate and the edge).isDefaultis unchanged: it runs when no conditioned sibling did. Scoped todecision: conditioned out-edges of any other node type keep the every-true-edge traversal (the census below found none).config.mode: 'inclusive'takes every out-edge whose condition holds, one successor at a time (neverPromise.all; the pin's positive control proves the instrument can see interleaving).5857171841).registerFlowparses every decision's config through the spec'sDecisionConfigSchemaand refuses the flow on any issue rooted atmode— the refinement (modebeside a non-emptyconditionslist, either member) and the value refusal — with the schema's own sentence atnode 'x' (decision) at config.mode, inside ADR-0031 regions too. Judged onmodealone, deliberately: the same parse also refuses an undeclared key, but that strictness binds at authoring by the standing decision in the module header ofschemaless-node-config.zod.ts, and refusing an inert extra key at boot would be a second behaviour change riding a ruling that ordered one. Measured reach of the alternative: zero decision nodes in either corpus carry a key other thanconditions, so promoting it later is cheap.os validatedoor.@objectstack/lintgainsflow-decision-mode-invalid(gating; the finding IS the schema's issue message, so both doors say one sentence) andflow-decision-inclusive-overlap(advisory, ruling item 4:mode: 'inclusive'with two or more conditioned out-edges; besideflow-decision-unconditional-branch, which is about an out-edge nothing gates).flow-decision-mode-inclusive-explicit(protocol 18): a decision with noconditionslist, nomode, and two or more conditioned out-edges (afaultedge is error routing; a blank condition is none; regions walked through the shared slot table) getsmode: 'inclusive'written, with a notice naming the count. One conditioned edge plus a default is left alone; an authoredmodeis left alone (idempotent by construction). No inference over the conditions, per the ruling. Paired D3 entryflow-decision-edge-branching-first-matchnames the D2 id as a whole word and carries the three-way judgment the diff asks for.MIGRATIONS_BY_MAJOR[18]wires the id and its rationale grows a sentence.decision-overlapping-edge-conditions.pin.test.tsis the contract pin now, per its own header.flows.mdx, theDecisionConfigSchemadocblock andmodedescribe, the module header (the declared-ahead sites of5852576993item 2),logic-nodes.ts, andengine.ts's traversal comment all describe both modes; the reference mdx is regenerated.Ruling C (
5863827385): stored rows take the new meaning — listed, not rewrittenThe ruling, verbatim:
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 noconfig.conditions, nomodeand two or more conditioned out-edges, the one-line fixmode: '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 bycollectDecisionModeReview, which runs the D2 entry's ownapplyover 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;formatStoredMigrationReportprints it with the fix, and the CLI andPOST /meta/_migrate-storedcarry it unchanged, someta.tsis 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 theconfig.conditionsfirst-match inbuiltin/logic-nodes.ts(untouched, still label-narrowing). Declaration order isflow.edgesarray order:traverseNextfilters that array in place;FlowSchema.parse(region transform included) and every conversion walker are copy-on-write maps that never reorder;normalizeStackInputnormalizes map-form collections at the stack level and never touches a flow'sedges;canonicalizeStoredFlowruns those same two. Grep for any edge re-sort acrosspackages/*/srcandpackages/*/*/src(edges.sort,sortEdges,.edges.slice().sort,localeCompareon edges): 0 hits. NOT MEASURED: the Studio designer's own serialization order at save time — objectui is not checked out in this container; server side,saveMetaItemcanonicalizes without reordering.2. The migration predicate — census. Corpus: this repository's examples at
10ea9eb2eandobjectstack-ai/hotcrmat2f7b2326(read-only), every flow module loaded and every graph walked including regions; platform packages ship no decision node (grep overpackages/platform-objects,plugins,services: only the executor and the README).mode)crm_convert_lead_wizard.check_converted: 1 conditioned +isDefault)lead_conversion.decision_duplicate, the #1555 node, now three conditioned edges)Every one of the 17 positives is a hand-written partition by inspection (a predicate beside its negation,
>beside<=,has()beside!has(), hotcrm'sCASE_HAS_OWNERbeside its exact complement,memberSource != "contacts"beside== "contacts"), so first-match changes none of their runs; the conversion still writes the key onto all 17, as ruled, and the D3 entry tells the author to delete it there. No decision in either corpus carries a config key other thanconditions.examples/**is outside this claim's surface and is not edited: the four in-tree positives partition, so nothing changes at boot.3. Stored flows — FAILED, and adapted. The assumption was that the conversion replays at rehydration like the other step-18 entries. It cannot: this is a DEFAULT FLIP (the old shape still parses and now means exclusive), and the flow rehydration seam serves post-flip authored bodies too —
canonicalizeStoredFlowis reached by the boot pull for code-shipped flows, byPOST /automation, bysaveMetaItem(every Studio save) and byduplicatePackage, all through one two-argument signature, none dated. Replaying there would rewrite every NEW exclusive decision into an inclusive one at registration and persist it at save, and the ruled default would be unobservable. The registry's own doctrine for this class (excludeConversionIds, the artifact door'sDEFAULT_FLIPS_NOT_REPLAYED_HEREforapp-hidden-to-unpublishedon #17885, the WITHDRAWNfield-required-notnull-explicitnote) says a seam that cannot state 「this body predates the flip」 refuses the entry by id. So:retiredFromLoadPath: true(no authoring window — 「不留过渡窗口」) and replays where the operator asserts the source's age:os migrate meta --from 17(the D3 chain), pinned both ways;canonicalizeStoredFlowrefuses it by id (CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, reason at the call site), pinned onparsed,storableand notices, with the chain as the firing control;sys_metadataflows take the new meaning — ruled C (5863827385). A decision saved before this release with two or more conditioned out-edges and nomoderuns 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 fixmode: 'inclusive';os migrate meta --storedlists every such node, report only.27a1a4598, the seat's answer A in5861311629).packages/metadata-core/src/artifact-forward-conversion.tslistsflow-decision-mode-inclusive-explicitinDEFAULT_FLIPS_NOT_REPLAYED_HEREbeside theapp-hidden-to-unpublishedprecedent, with its reason: the door's trigger is the artifact's declaredengines.protocolfloor,^17.0.0is whatcreate-objectstackstamps, so an app scaffolded today against the exclusive contract lands inside the window and would otherwise be handed an inclusive gateway it never asked for. The door pin has four legs (subject, the strict parse the door feeds, negative, firing control), and the engine seam's and the entry's docblocks now cite the door precisely.4. Serial state — moved, merged.
origin/maingained #20286 (view-overlay-owner-hidden-removed) on the same registry lines;os-regen-merge.shmerged it (both entries kept in landing order inconversions/registry.tsand inMIGRATIONS_BY_MAJOR[18], rationale concatenated),gen:migration-registryregenerated to an identical file,check:generatedfound every artifact current, and every sibling symbol was asserted present on both sides by exact-name grep (viewOverlayOwnerHiddenRemoved3/3,view-overlay-owner-hidden-removed9/9,view.zod.tsretiredKey21/21).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 isStoredMigrationReport+StoredDecisionModeReviewthere, covered byClause-②: yes; the renderer is also metadata-protocol's, someta.tsneeded no edit. (2) The listing reads the stored body directly, with no engine, through the D2 entry'sapplyby id; a flow row skipped for want of an engine still lists. (3)origin/main15bf186f5(32 commits) merged throughos-regen-merge.shasdf3f6a00: two hand-written conflicts (both registries; main'sform-layout-inline-grid-to-vertical,currency-config-precision-removed,permission-rls-tags-removedkept ahead of ours), the reference mdx regenerated (62771d8e),gen:migration-registrya no-op on the merged entries, sibling ids and symbols asserted present 2/2 and 2/2. (4) #20316: branchclaude/issue-20316-flow-node-config-build-doorsexists, no PR; overlap with this PR isconversions.test.tsandmigrations/registry.tsonly; nothing here touchesregisterFlow.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 theDEFAULT_FLIPS_NOT_REPLAYED_HEREarray and its reason docblock) andpackages/metadata-core/src/artifact-forward-conversion.test.ts(the door pin). Two files the claim did not spell are recorded there as covered:packages/spec/src/conversions/registry.ts(where every D2 conversion lives) andpackages/lint/src/index.ts(the two rule-id exports, required byrule-id-barrel-exports.test.ts). ⛔ Not touched:flow-node-expression-paths.ts,examples/**,packages/cli/**,packages/runtime/**,packages/rest/**,packages/spec/src/contracts/**, objectui. The ruling C round addspackages/metadata-protocol/src/{stored-migration,protocol,index}.ts,protocol.stored-migration.test.tsandcontent/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 recordsskipped; declaration order decides (the same predicates reversed take the other branch);mode: 'inclusive'→ both run nested, no skipped step; none true →isDefaultruns in both modes; a true edge beside a default passes the default over in both modes; aconditionslist still narrows by label; registration refuses the pair (either member) and a bad value with the spec sentence, inside a loop body too, and the flow is never armed; the four controls register; the rehydration seam leaves the two-branch shape unrewritten whileapplyMetaMigrations(stack, 17, 18)rewrites it; a non-decision node keeps every-true-edge.conversions.test.ts: the fixture pair (2 notices) plus the predicate's edges, region reach, idempotence, the authoring funnel's silence, and the seam refusal with its firing control.lint-flow-patterns.test.ts: both rules, gating vs advisory, controls, regions, no double report through rule (2).migrations.test.ts's census pin sees the D3 entry naming the D2 id.artifact-forward-conversion.test.ts(27a1a4598): a^17.0.0-floor artifact carrying a two-branch decision passes the door with nomodewritten, no notice for the id and the same reference back; the strict parse the door feeds receives nomode;^99.0.0shuts the window; and the same fixture throughapplyConversionswithincludeRetired: trueand no refusal comes back{ mode: inclusive }with the entry's notice (firing control). Ruling C (5863827385):decision-overlapping-edge-conditions.pin.test.ts— a decision as a pre-18 row stores it (JSON text, nomode, the hotcrm#1555 pair) passescanonicalizeStoredFlowwith nomodewritten, registers and runs FIRST-MATCH (second branchskippedon its edge); the same body throughapplyMetaMigrations(stack, 17, 18)carriesmode: inclusiveand 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;--applychanges no byte and writes no history; a row rewritten for another conversion persists nomode; no engine still lists; region path; controls (eithermode, one edge plus default,conditionslist,faultedge); the list equals the--from 17chain'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 wasgit checkout HEAD -- ABS_PATHunder a trap, proven bygit diff HEADclean andgit hash-objectequal to the HEAD blob. No dist leg was owed: both suites resolve their subject throughsrc(../engine.jsinside service-automation;./registry.jsinside spec).if (exclusive && anyConditionMet)→if (false && …). Bloba60861d3985717a743cb32c16d9e3ba925dee3c7→f0e25d39262ae22b38ef67b5affbba494c0023bf. Ablated run: 6 failed (exactly the exclusivity, skipped-step, declaration-order, written-exclusive, default-passed-over and seam-runs-exclusive pins), 15 passed (the controls, inclusive, default and registration pins). Restored:a60861d3…on disk and at HEAD.MIN_CONDITIONED_EDGES = 2→3. Blobfd1a7902d480b791e7f53116eb38c97ad268fb78→a03f5cbdf9f742aabf42e8400a1fc5df50b50d1b. Ablated run: 5 failed (the fixture pair, the wiring pin, the two-edge rewrite, the left-alone pin, the seam-refusal firing control), 216 passed. Restored:fd1a7902…on disk and at HEAD.27a1a4598): the id removed fromDEFAULT_FLIPS_NOT_REPLAYED_HERE(anchor 1→0, marker 0→1). Blob16742f49e72eaa98214eca097b9b14cef03e8809→26220cf59c92d7b4daf75a74b17e5a076156503c. Ablated run: 2 failed (the subject leg —modewritten — and the strict-parse leg), 27 passed (the firing control and the negative stayed green). Restored:16742f49…on disk and at HEAD.18cbf4e2):protocol.tsfor (const node of collectDecisionModeReview(body)) {emptied (.slice(0, 0)), blob711fded5ddea7d37b4f6d2a52f7b7a6a80db8081→ce0c296d5134527c0634c1932ec88b59c6285dbc; ablated run 5 failed | 34 passed (the five listing pins; region, controls, parity and the nothing-to-review control green); restored711fded5…on disk and at HEAD.18cbf4e2):engine.tsexcludeConversionIds: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION,removed (a read-path completion), blobdaac6de07304ae4051f1681ab4311c447a8ad3a9→a3555237ccca29ff4ad888bd009cc97be4007ae8; ablated run 7 failed | 15 passed (the ruling C stored-row pin, both seam pins, four exclusive-traversal pins; the--from 17source pin, inclusive, default, registration and boundary green); restoreddaac6de0…on disk and at HEAD.Tests, at
e92edee5b, every exit captured after a redirectpnpm --filter @objectstack/spec test→ exit 0:Test Files 554 passed (554) · Tests 16366 passed | 1 todo.pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2→ exit 0:Test Files 147 passed (147) · Tests 1782 passed (1782).pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2→ exit 0:Test Files 111 passed (111) · Tests 4313 passed (4313).typecheckfor the same three packages → exit 0 each (check:test-typecheckOK on each test layer).DecisionConfigSchemaoutside these packages:metadata-protocol's JSON-projection walk (a refinement projects byte-identically) andconfig-expression-ledger.test.ts(in the service-automation run above); no other importer of the traversal exists (registerFlowcallers inruntimeandplugin.tsare unchanged call sites).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 114 commands on this branch; all 114 ran one92edee5bwith exit 0 (check:dual-build-cjs-loadsandcheck:type-check-debtfirst answered exit 3, PREREQUISITE NOT MET, until the wholepackages/*closure was built — 71/71 — then 0);--ranreconciliation:114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN.check:generatedon the merged tree: every artifact current aftergen:docs.spec-changes.jsonand the upgrade guide render no protocol-18 id yet (control:report-joined-chart-removed0 hits too), so their green is genuine, not a missed regeneration.27a1a4598, readings at that head:pnpm --filter @objectstack/metadata-core exec vitest run --maxWorkers=2→ exit 0:Test Files 16 passed (16) · Tests 289 passed (289); metadata-coretypecheck→ exit 0. Re-run because the diff reaches them (docblock edits inengine.tsandconversions/registry.ts): service-automationTest Files 147 passed (147) · Tests 1782 passed (1782), specTest Files 554 passed (554) · Tests 16366 passed | 1 todo, both typechecks exit 0; lint is not reached and was not re-run. Gates: the same 114 derived commands (0 added, 0 dropped), all exit 0 on27a1a4598;--ran:114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN.check:type-check-debtfirst refused (exit 3) because metadata-core's cache-restoreddist/was older than its source after the ablation's restore rewrote the file; a directpnpm --filter @objectstack/metadata-core build, as the gate prescribes, and a re-run gave 0.18cbf4e2: spectest557 passed (557) · 16508 passed | 1 todo; spectest:repo35 passed (35) · 634 passed (634); metadata-protocol189 passed | 3 skipped (192) · 2745 passed | 19 skipped (2764); service-automation147 passed (147) · 1783 passed (1783); metadata-core16 passed (16) · 289 passed (289); lint113 passed (113) · 4713 passed (4713); cli unit230 passedplus the two published-subpath pins2 passed (2) · 29 passed (29)after a post-mergepnpm 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 andclient-react/metadata-protocolwere built). Narrowed eslint over the PR's 18 changed.tsfiles: 0 errors, 0 warnings (no type-aware linting ineslint.config.mjs). CI on18cbf4e2: 33 success, 2 skipped, all seven required contexts success.Changeset grade, measured at landing
npm
latest@objectstack/specis17.4.0(npm view, re-measured 2026-09-28), whose publishedDecisionConfig.jsondeclaresconditionsonly;.changeset/19867-decision-config-mode.mdand.changeset/20168-…mdare still unconsumed, somodeis unreleased and reaches its first release with the traversal that reads it and the conversion that writes it.Clause-②: yesper the ruling's item 2 (the D2/D3 entries and the two lint rules widen the published surface; nothing published narrows).minorfor@objectstack/spec,@objectstack/service-automationand@objectstack/lint, with the BREAKING banner, the FROM → TO block and the disposition markerregistered flow-decision-mode-inclusive-explicit(the changeset file carries it in the gate's own form).@objectstack/metadata-protocolminor(the report widens) and@objectstack/metadata-corepatch(the artifact door's refusal) join the bump list; the changeset carries the ruling C BREAKING section.Acceptance notes
27a1a4598): the artifact door's refusal offlow-decision-mode-inclusive-explicit, per the seat's answer A (5861311629).5863827385) and landed in this round: stored rows take the first-match meaning (BREAKING, stated with the fix),os migrate meta --storedlists them report-only, and the three pins hold it. objectui#10750 (the designer writesmodeon save) proceeds on its own card and is not this PR.origin/mainmoved two commits after this round's merge (0d7ed5a3regeneratespackages/spec/src/migrations/registry.ts); the landing lap merges it throughos-regen-merge.sh.skills/objectstack-automation/SKILL.mdline 65 (「routed by edgeconditionpredicates」) stays true and does not mentionmode; governed surface, not touched.VALIDATION_ERROR400 throughflowDefinitionRefusal(unchanged code path); not pinned here, the runtime package is outside this surface.Generated by Claude Code