Skip to content

fix(service-automation): boot-time flow precedence classifies contenders by the loader's set - #20880

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20864-precedence-loader-set
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20864-precedence-loader-set

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20864
Clause-②: yes (widening)

Boot-time flow precedence now takes which same-named contender is the packaged one from the loader's set, the reader the engine has held since PR #20853, instead of from the flow bodies' package-provenance stamps. This finishes rule 1 of the ruling recorded on #20761 (5904938166) for the last reader it names, precedence. packages/services/service-automation is a declared cross-lane touch (domain:services) from the domain:cli seat, as it was on #20761.

Base 72f8c3820 (claim), head 6a028afb2b. main was not merged in.

What changed

  • flow-precedence.ts. resolveFlowPrecedence(items, logger?, packagedFlowOwner?) and describeFlowContender(item, packagedFlowOwner?) now take the reader as an optional last argument. Its type is PackagedFlowSource, the one AutomationEngine.setPackagedFlowSource already takes.
    • A contender is packaged only when the set holds its name.
    • Inside a name the set holds, the loader's entries are told apart from a same-named tenant row by the registry's own per-entry artifact test (isCodeArtifactBody). The set's own lookup (SchemaRegistry.getArtifactItem) applies that same test to each entry.
    • With no reader, nothing is packaged. This is the engine's fail-closed answer.
    • The set is asked once for each contested name. A name with one contender asks nothing.
  • Tie-break. The package-id tie-break now applies within the packaged rank only, as the function's docblock always said. Two tenant-ranked contenders keep their arrival order, so a body's own id cannot win it the armed slot.
  • plugin.ts. The one call site passes the engine's own packagedFlowOwner, which packagedFlowReader has fed since init(). Precedence and every other classification the engine makes now read one source. There is no second set.
  • Changeset. @objectstack/service-automation gets a minor bump, and the changeset carries the one-line migration for direct callers.

Clause-② reading

yes (widening): two barrel-exported functions gain an optional parameter. The claim declared no on the condition that no public member is added. A new accepted argument widens the public signature, so this line is re-declared, as the claim instructs.

No accept set shrinks. A body's claim of package provenance was never a declared input to precedence. The stamps are server-derived read decorations, which is the same negative boundary the PR #20853 record judged for this rule.

Premise, measured before the change (at 72f8c3820)

These runs used a scratch vitest file (not committed) and a real SchemaRegistry from @objectstack/objectql.

  • M1. A body stamped with a package's provenance, for a name no set holds, was classified package.
  • M2. On pure input, a forged package id beside the loader's entry took the armed slot by lexicographic order.
  • M3. A real registry was filled with the loader's shapes and the hydration's shapes (the tenant marker, and the artifact graft). Every code-shaped listed entry had its name in the set: the invariant held for all 4 code-shaped entries among the 6 listed. So, as the PR fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853 record read, this is not reachable from an authoring door at boot. The change is defence in depth and completes the ruling.

The same file after the change (at 6a028afb2b):

Tests (at 6a028afb2b)

  • pnpm --filter @objectstack/service-automation test: 159 files, 1995 tests passed.
  • pnpm --filter @objectstack/service-automation typecheck: tsc is clean. check:test-typecheck is OK: the test layer compiles, with 0 debt.
  • New flow-precedence-loader-set.test.ts (11 pins):
    • A stamped contender ranks tenant-authored when the set does not hold its name, and the same contender ranks packaged when the set holds it.
    • A stored tenant row stays tenant-authored inside a held name.
    • The same two bodies rank by the set, not by their bytes.
    • The shadowing record and the pull warning name the armed body in both arrival orders.
    • Tenant-ranked contenders keep arrival order.
    • With no reader, the classification fails closed. A reader that answers an empty owner fails closed too.
    • Each contested name is asked about once.
    • The real AutomationServicePlugin boots three times: a held name (answered by a protocol stand-in's packagedArtifactOwner), an unheld name, and no protocol service at all.
  • flow-name-shadowing.test.ts (Packaged flow silently replaced by a same-named runtime flow: listItems returns both, the engine keys flows by bare name, and Map order decides the winner #11997): precedence now gets the registry's artifact-view owner. The owner is attached to the engine and read back through packagedFlowOwner, exactly as the pull does. The expectations are unchanged.
  • Dogfood regression, flow-provenance-server-held.dogfood.test.ts (PR fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853's suite): 13 of 13 passed against a rebuilt dist/. The dist marker count is 1 for each of the two changed spots.

Ablations

The fix was committed first. Each mutation ran through scripts/ablation-replace.mjs. For every one, the anchor went from 1 hit to 0, the blob changed, the restored blob equals HEAD, and git diff HEAD is empty. The subject resolves by relative import from src/, so dist/ is not on the path.

  • A1, the classifier ignores the set (the stamps decide again): 6 of the 11 pins go red, 4 pure and 2 plugin boots.
  • A2, the boot pull stops passing the reader: 1 pin goes red, the held-name plugin boot.
  • A3, the id tie-break orders tenant-ranked contenders again: 1 pin goes red, the arrival-order pin.

All three went red in the predicted direction.

Gates (at 6a028afb2b)

  • dispatch-gates --commands, run with no paths: the change set was derived from merge base 72f8c3820 and covers 5 paths. That gave 61 commands. Every one ran, and every one exited 0.
    • --ran with exit codes: 61 derived, 61 run, 0 NOT MEASURED, 0 unrun.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET, because 8 packages were not built in this worktree. After they were built (all turbo cache hits), it exited 0.
  • Beyond that list: check:startup-registry-verdict and check:durability-log-level are green.
  • Lint, as a proven narrowing rather than pnpm lint:
    1. The population comes from eslint's own config. --print-config resolves the lint config for each of the 4 changed TypeScript files (6 rules for the source files, 5 for the tests), and none is ignored. The changeset falls outside the config's TypeScript/JavaScript files.
    2. The count comes from --format json: 4 files, 0 errors, 0 warnings.
    3. Untouched files cannot change verdict. No file's resolved config sets parserOptions.project or projectService, so type-aware linting is off. The custom rules read only the linted AST, plus a config-level baseline that this diff does not touch.

Acceptance notes

  1. Per-name limit. The reader answers per name. Inside a held name, a code-shaped entry that is not the loader's (a forged package id with no tenant marker) still ranks packaged under its own id. It can win the lexicographic order (M2, after the change). It is not reachable at boot: every code-shaped registry entry is either the loader's or carries the artifact's own grafted envelope. Closing it needs a per-entry answer from the metadata protocol, which is outside this card's surface. Carrier: none.
  2. Grafted stored rows. A stored row of a held name hydrates with the artifact's envelope grafted onto it, so it ranks packaged beside the loader entry. Kernel phase order (the loader comes first) then arms the loader body, and the receipt lists that package twice. This PR does not change that: M3 was identical before and after. ADR-0126 §7.1 leaves the shadow diagnostics to Packaged flow silently replaced by a same-named runtime flow: listItems returns both, the engine keys flows by bare name, and Map order decides the winner #11997.
  3. Stale docblock. FlowContender's docblock in engine.ts still describes package as the per-body artifact test alone. That file is outside this card's surface. Carrier: the next PR that touches that type.
  4. No-reader compositions. In a composition with no reader, two packages that ship one bare flow name now keep arrival order instead of package-id order, because nothing is packaged there. Every real composition attaches the reader.

Session: session_01VvcEokUG1tvVxkceYfR5XB, branch claude/issue-20864-precedence-loader-set.


Generated by Claude Code

…ers by the loader's set

resolveFlowPrecedence and describeFlowContender take the loader's-set
reader (PackagedFlowSource); the boot pull hands it the engine's own
packagedFlowOwner. A contender is packaged only when the set holds its
name; with no reader nothing is packaged (the engine's fail-closed
answer). Body stamps stay on the contender for display only.

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

Tenant-ranked contenders keep arrival order (the id tie-break stays within
the packaged rank, as the precedence docblock states); the #11997 suite
hands precedence the registry's artifact-view owner the way the boot pull
does; a new suite pins the classification, the shadowing record, the
fail-closed composition and the plugin wiring.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 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 — 6 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 9905e61ca2fddb43d266c23cdb12ced5019c1a74 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9905e61ca2fddb43d266c23cdb12ced5019c1a74

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a028afb2bededfcb9ff96259e9ce0e832beb41d
Local-runs: none

This is the record of record for PR #20880 at 6a028afb2, the precedence half of ruling rule 1 on card #20761 (5904938166), carried by card #20864 and closing ① (a) Residual 2 of the PR #20853 record (5911889128).

Inputs:

Disclosure is kept at the card's level: doors, roles, codes and statuses. The body's package-provenance stamps, the tenant marker and the artifact's envelope are named abstractly here.

① Derived judgments

(a) One source — RIGHT. Rule 1 is now delivered for the last reader it names.

  • resolveFlowPrecedence(items, logger?, packagedFlowOwner?) and describeFlowContender(item, packagedFlowOwner?) take the reader AutomationEngine.setPackagedFlowSource takes, and the plugin's one call site (the boot pull in start()) passes engine.packagedFlowOwner, which packagedFlowReader has fed since init(): the protocol's packagedArtifactOwner, through lookupArtifactItem to SchemaRegistry.getArtifactItem, resolved at question time with nothing cached. That is the chain the §7.3 guards, the toggle door, the arming gate and the activation attribution already ask, and the lookup the locked-base verdict asks. There is no second set, and the private loaderSetOwner applies the engine's own non-empty-string normalization to the reader's answer, so precedence and the engine cannot disagree on a name.
  • The set is asked once per contested name and every contender of that name is judged against that one answer (pinned). A lone contender asks nothing, which is right: precedence decides nothing there and produces no receipt.
  • With no reader, and with a reader answering an empty owner, nothing is packaged (pure pins and a plugin boot with no protocol service): the engine's fail-closed answer, consistent with the fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853 record ① (e).
  • No other production caller exists at the head: repo-wide, the two functions are called only from plugin.ts and the two suites. The CLI serve banner and the plugin's bootstrap audit render getShadowedFlows() receipts downstream of this classification, so they agree with it by construction.
  • One precision point, not blocking: the describeFlowContender docblock says that inside a held name a body's stamps "can only keep an entry OUT of the packaged rank, never admit one". Read literally, that is not what the code does: inside a held name the per-entry artifact test is the admission, and the sentence holds only under the boot invariant established in (b). The PR's Acceptance note 1 states the limit correctly; the docblock overstates it. Folded into Residual 1.

(b) The per-name limit, judged adversarially — UNREACHABLE at boot, so a named residual, not a FAIL.

  • What populates the precedence input: the registry's own flow list at automation start(), which exactly three producers write. (i) The loader's entries under package-scoped keys, stamped by the shared protection routine with the package's id and package provenance. (ii) The boot hydration of stored rows, in objectql's start() after every plugin's init() has run: it registers under the bare key with no package, forces the tenant marker onto a copy of every row first, and then grafts the artifact's own envelope (id, version, provenance) over it when a real artifact of that name exists. The graft's lookup scans the package-scoped entries regardless of the row's own binding, so what lands is the loader's own envelope, never the row's, and the bare slot holds one row per name (last hydrate wins). (iii) The /meta save's write-through, which registers through that same hydrator with the same forcing and graft, and which since PR fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853 refuses a code-shaped assertion on an unheld name (422) and a held name as a locked base (403) before anything is written. The automation create, update and clone doors register in the engine only, never in the registry; inline app flows are never promoted to registry flow items; and precedence runs once, at start(), so a runtime registration cannot re-enter it.
  • Therefore every code-shaped entry the precedence input can hold at boot is either the loader's own or a hydrated row carrying the loader's own id and provenance for a name the set holds. The M2 shape, a code-shaped entry under a held name whose id is not the owner's, has no producer after PR fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853, from any door or any stored row. The dev's after-change M3 (every code-shaped listed entry in the set) agrees with this source reading, and the pin that a stored tenant row inside a held name stays tenant-authored covers the one real second entry a held name has.
  • What winning would give it, were it reachable: the body the boot pull arms for that name, meaning which definition dispatches, while the §7.3 guards, the toggle door and the activation attribution read the set by name and would not move. That is body substitution under a packaged name, so it is not harmless; it is unreachable. The closing shape is a per-entry answer from the protocol (the artifact entry itself, so precedence compares identity instead of stamps), which is outside this card's surface. Residual 1, carried for the seat.

(c) The tie-break — RIGHT, and no behaviour changes beyond what the PR says.

  • Rule 2 (lexicographic package id) has always been documented as within the packaged rank; the merge-base code applied it to both ranks, so a body's own displayed id ordered tenant-ranked contenders. It now applies within package only, and tenant-ranked contenders keep arrival order.
  • With the reader attached this is unobservable at boot: the bare slot brings at most one tenant row per name, and a loader entry under a held name is packaged, so no name can carry two tenant-ranked contenders. It is observable only in a composition with no reader, where two packages shipping one bare name now keep arrival order instead of id order (Acceptance note 4, disclosed), and for a direct caller of the barrel export that passes no reader (the changeset's migration line).
  • The Packaged flow silently replaced by a same-named runtime flow: listItems returns both, the engine keys flows by bare name, and Map order decides the winner #11997 suite keeps every expectation with an explicit set: the rehydration sentinel and a tenant overlay bound to a real package still rank runtime, the tenant overlay still beats the package, two packages still order by id, and the shadowing receipt plus the pull warning name the armed body in both arrival orders (pinned). What changed in the report is exactly what the changeset states: a stamped body of an unheld name now records as a runtime row, its id kept on the record for display while the operator phrase never prints it.
  • One pre-existing observation, not introduced here: when two packages ship one bare name, the set names one owner (the first package-scoped entry in registry order) while precedence arms the lexicographically first package's body. Both are packaged, so every set reader agrees on "packaged"; only the activation row's attribution could name the other package. ADR-0048 §3.4 warns that case; it is not this card's.

(d) Changeset and PR prose — ACCURATE, within the discipline, nothing undelivered.

  • @objectstack/service-automation minor, Clause-②: yes (widening). It names exactly the two widened signatures and the type they take, the fail-closed answer, the arrival-order rule, the unchanged boot outcome for a registry the loader and the hydration filled, and the one-line migration for direct callers. Every sentence is delivered at the head and pinned.
  • Neither the changeset, the PR body, the report nor a test title spells a request body, header or field key; the stamps are named as provenance. No model identifier in the three commits (model-free trailer pair on each), the PR body or the changeset. No tracker number in runtime prose.

Surface inventory: two barrel-exported functions gain an optional trailing parameter; the boot pull passes the engine's reader; no route, schema, query set or status changes; no governed path.

② Semver level

yes (widening) + minor: RIGHT. Two barrel-exported functions accept a new optional last argument, which widens the public signature; the claim's no was conditional on no public member growing and instructed exactly this re-declaration. The change in what the old arity computes (nothing is packaged without the reader) is not a (narrowing): no accepted input is refused, and trusting a body's provenance stamps was never a declared input of precedence. The stamps are server-derived read decorations, ADR-0126 §2 locks the packaged base and ADR-0131 D6 seals managed definitions, which is the same negative boundary the #20853 record judged for this rule. The migration sentence is present for direct callers, Check Changeset is green, and no ADR-0087 disposition is owed.

③ Boundary flags

  • Deviations, all five answered: (1) the Clause-② re-declaration is right (②); (2) the tie-break restriction is right, sits in the claimed file, and is disclosed with its one observable consequence (① c); (3) the model-free trailer pair and the session-URL footer are the AGENTS.md form, not a deviation; (4) the two scratch measurement files are absent from the diff (5 files, none temporary); (5) worktree removal is housekeeping.
  • Out-of-scope findings, all three answered:
    1. The per-name reader limit: judged in ① (b). Unreachable at boot, harmful only if it were reachable. Residual 1, carried for the seat as a hardening follow-up (a per-entry answer from the protocol, domain:engine surface). Not blocking.
    2. The grafted stored row of a held name: a pre-existing shape this PR leaves byte-identical (M3 before and after). The loader's body stays armed by arrival order, which is the ADR-0126 Regime C outcome for a sealed definition, and the receipt naming the package twice is Packaged flow silently replaced by a same-named runtime flow: listItems returns both, the engine keys flows by bare name, and Map order decides the winner #11997's diagnostics domain. Escalated one step further from this review's source reading, unmeasured: the kernel:ready sync re-registers every name from the protocol's execution view with no precedence, and that view's per-slot merge picks the last row that is the package's own or package-less, which for a held name with a stored row is the stored body. The sync would then re-arm the stored body after the boot pull armed the loader's. Reach is a stored row of a held name, which every door has refused as a locked base since access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 and fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853, leaving pre-lock rows and the writable hatch. Not this card's file surface and not changed by it; the seat should measure it on a real boot with such a row and file it if it reproduces. Residual 2.
    3. The stale FlowContender docblock in engine.ts: right to leave, outside the card's surface; carrier named (the next PR touching that type).
  • Acceptance note 4 (no-reader compositions keep arrival order for two packages of one bare name): right; every real composition attaches the reader at init(), and the fail-closed answer is the declared one.
  • Residuals from this review, none blocking: Residual 1 (① b, with the docblock precision in ① a); Residual 2 (above); the two-package attribution observation (① c).
  • origin/main has advanced past the merge-base; the diff reviewed is the net diff against the merge-base.

Implemented-by: claude/issue-20864-precedence-loader-set
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 15:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 27bf358 Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20864-precedence-loader-set branch September 30, 2026 15:57
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: boot-time flow precedence still classifies its contenders from body stamps, not the loader's set (the remainder of #20761's ruling rule 1)

2 participants