Uh oh!
There was an error while loading. Please reload this page.
fix(automation): resolve same-named flow definitions deterministically per ADR-0005 - #12026
Conversation
A runtime-authored flow reusing a packaged flow's name silently and non-deterministically replaced it. The registry coexists both by design (ADR-0048 §3.4) and listItems returns both with no precedence, while the engine keys flows by bare name — so the boot pull registered both under one key and Map iteration order decided the survivor. Apply the ADR-0005 overlay precedence ADR-0048 §3.4 routes this case to (runtime overlay wins over the packaged artifact), warn per colliding name, and leave an admin-visible receipt for the shadowed definition. Fixes#11997 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 3db178eecc98d2ce3571a626af7437a9061ddb0f && git checkout 3db178eecc98d2ce3571a626af7437a9061ddb0f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1e79aa4f812708d5d097ca33d9b7040e02b958c6 b5ca58cec2d575c096d7d2aabf0b252c5cd88420 && git checkout -B drift-repro 1e79aa4f812708d5d097ca33d9b7040e02b958c6 && git merge --no-ff b5ca58cec2d575c096d7d2aabf0b252c5cd88420
node scripts/docs-audit/affected-docs.mjs --json 1e79aa4f812708d5d097ca33d9b7040e02b958c6
|
os-trump
commented
Aug 25, 2026
Contract review PASS at Generated by Claude Code |
Fixes#11997
⭐ Clause-② declared: YES. This changes collision behaviour on a served surface — a flow. Today the armed flow is decided by Map iteration order, so making the winner deterministic changes which flow fires on some boots.
needs:contract-reviewis carried on this PR as the second half of the dual carrier (the card holds the first). ⛔ Not to be cleared, flipped ready, or auto-merged from here.The defect, confirmed at runtime before anything was changed
The card was an explicit static read ("I did not run this end to end … the registration-order claim in particular deserves a runtime confirmation"). It does hold. Measured with a real
SchemaRegistryand a realAutomationEngineon unmodifiedorigin/main:Same two definitions, opposite outcomes, decided by nothing but Map iteration order.
listFlows()returned exactly['opportunity_approval']in both cases andgetFlowRuntimeStates()had one entry — so nothing observable distinguished the two boots.One extra finding the card did not have:
Registry.registerItem's existing[Registry] Collisionwarning fires in only one of the two orders (packageId && collection.has(bareKey)— i.e. a runtime row exists and then a package ships the name). In that very order the warning promises "The runtime row will shadow the package value" while the engine armed the PACKAGED body. The one diagnostic that did fire was actively contradicted by the thing it warned about.The direction is ADR-0005's, not a choice made here
Triage's premise-first condition routed through ADR-0048, which does not restate the direction. ADR-0005 does, twice:
1. sys_metadata … ← overlay (wins)above2. SchemaRegistry / MetadataService … ← artifact defaultand ADR-0048 §1.5 lists the runtime/DB overlay as "the sanctioned override path" under what is not a collision. So the runtime overlay wins; the packaged artifact is the default it overlays. This PR makes the engine agree with that deterministically instead of agreeing with whatever Map order produced.
What changed
resolveFlowPrecedence. The order is total and never reads iteration order:runtimebeforepackage, then lexicographicpackageId. That second rule covers ADR-0048 §3.4's legitimate two-packages-one-name case, which the engine's bare-name map cannot represent but still has to answer deterministically.[#5048), with the facts also inmeta. Repeated atkernel:bootstrappedbeside the other automation audits.getShadowedFlows()lists each contested name with its armed and shadowed definitions, andgetFlowRuntimeStates()rows carryarmedFrom/shadowedfor contested names. Previously the displaced definition was invisible by construction. ThePulled N flow(s)line now counts distinct names, not registrations — the card noted N counted the latter.⛔ The engine's flow map is still keyed by bare name. Making it package-aware is the larger change the card names and the ADR does not require; the receipt is a side map that never affects dispatch.
isCodeArtifactBodyis exported from@objectstack/objectqlrather than re-derived in the automation plugin. That test exists precisely so callers cannot drift into a second answer to "does a code package ship this name?", and a bare_packageIdcheck gets it wrong twice — thesys_metadatarehydration sentinel, and a tenant overlay bound to a real package id (cloud#970). Both are pinned.Verification
All gates below ran against
b5ca58cec2, the head of this branch, with a clean tree.pnpm --filter @objectstack/service-automation test—Test Files 89 passed (89)/Tests 1051 passed (1051)pnpm --filter @objectstack/service-automation build(this package has notypecheckscript; its build is the type check) —DTS ⚡️ Build success in 6329mspnpm --filter @objectstack/objectql typecheck—tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json, exit 0pnpm check:type-check-debt—--re-measure: OK — 32 ledger entr(ies) re-measured in 216.8s, 1898 raw tsc error(s) total, none above its recorded number.pnpm check:type-check-coverage—OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledgerpnpm check:engine-double-contract—OK — 405 pinned, 133 in the DEBT ledger, 2 exempt.pnpm check:where-matcher—✓ 297 matcher(s) discovered, 297 answer the combinator battery correctly or refuse it loudlypnpm check:query-options-erasure—✓ ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newpnpm check:cross-package-test-inputs—OK: 16 package(s) read outside themselves, all declaredpnpm check:test-source-alias—OK — 72 packages with tests scannedpnpm check:type-source-resolution—OK — 93 tsc program(s) across 77 packages scannedpnpm check:published-files—✓ 69 publishable package(s) of 78 workspace member(s)pnpm check:nul-bytes—✓ self-test: 75 assertions over a temp git reponode scripts/check-engine-split-ratio.mjs—ratio: 97.6%, deferred per ADR-0076 OQ#5Gates derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reported the list as derived from this repo atb5ca58cec2. Two gates first came back NOT MEASURED and were re-run rather than counted:service-automation typecheck(ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT— no such script; the build is the type check) andcheck-engine-split-ratio(refused on a shallow clone, re-run after--shallow-since).Declared narrowing: the round was narrowed to the ratchet family plus
service-automation, per dispatch. Repo-widepnpm lintwas replaced with a targeted run, and the narrowing is measured, not assumed: eslint's own--format jsonreports 7 files linted, 0 errors, 0 warnings, andeslint.config.mjsstates this repo "runs oneeslint.config.mjs, which never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file" — so this diff cannot move the verdict on any file it does not touch. CI runs the full farm regardless.Ablation
The failing assertions were predicted in writing first, then observed exactly. Ablation: strip the grouping from
resolveFlowPrecedenceso every item becomes its own winner — the pre-fix pull.Predicted to fail (7): arms-same-definition-regardless-of-order · one-flow-per-bare-name · warns-loudly · admin-receipt · two-packages-by-packageId · first-seen-order · tenant-overlay-beats-package. Predicted to pass (4): registry-returns-both · arms-runtime-over-packaged (accidental — package-first leaves RUNTIME last) · single-definition-silent · sentinel-classification.
Observed:
Tests 7 failed | 4 passed (11)— the same seven, test for test.The mutation was proved on disk before the run (injected marker present,
git diff --stat+7 lines), not inferred from an editor exit code. No rebuild was needed for this leg and that is load-bearing: the test reachesflow-precedence.tsthrough a relative import, which vitest resolves from source, while the unaliased@objectstack/objectqlimport resolves todist/and was built and left unmutated. The script carriedtrap '<restore>' EXIT INT TERM. Restore was verified byte-for-byte — marker absent, disk/index/HEAD diffs all empty, andgit hash-objectequal togit rev-parse HEAD:<path>(07ed8e6e…).Generated by Claude Code
Generated by Claude Code