Skip to content

fix(service-automation, metadata-protocol): a shipped flow name arms the loader's body at both boot steps, and a stored row of that name is reported as shadowed (#20913) - #20942

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20913-sync-one-precedence
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20913-sync-one-precedence

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20913
Clause-②: no

What

For a flow name the loader ships from a managed package, a cold boot now arms the loader's body at both boot steps, and a stored row of that name is reported as its own shadowed contender, never armed. Triage direction 5916734614, ruling 5904938166 rule 1, ADR-0126 §2 / §3, ADR-0131 D6.

One decision, three seams:

  1. One precedence for every arming step (service-automation/src/plugin.ts). The kernel:ready sync and the metadata:reloaded re-sync now resolve the protocol's flow view through resolveFlowPrecedence with the engine's own packagedFlowOwner, the same call the boot pull makes. The three callers share one private helper, resolveFlowContenders. Before, both syncs registered every listed body one after another, so the last body listed for a name was the one armed.
  2. The sealed base wins a name it ships (service-automation/src/flow-precedence.ts). Within a name the loader's set holds, the loader's entry ranks before a stored row. flow is Regime C: "⛔ Never silent override, never an overlay read path". A package contender exists only inside a held name (since 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) #20864), so the rank changes nothing outside one. The collision warning now says which rule armed the body and names the remedy (clone under a new name, or switch the packaged flow off).
  3. A stored row of a shipped flow name is its own row (metadata-protocol/src/protocol.ts).
    • The hydration registers it without the artifact's protection envelope. Before, it carried the loader entry's package-provenance stamps, so both registry contenders read as the package's.
    • The flattened view (both faces) serves a shipped flow name from the loader's entries alone: the stored row is not merged into the package's slot, and the registry's hydrated copy of it does not stand in either.
    • The row stays at rest and is still hydrated, as the tenant row it is, so the boot pull reports it.

⛔ Not decided here: what happens to hatch-written rows themselves (keep, refuse, migrate). That is #15206's. This PR only makes them "never armed over the base".

Readings

Showcase composition on a database file, measured on a cold boot after a stored row was placed at rest under showcase_urgent_task_alert between two boots. Before = base 4d0b9cd542; after = 22d8d3593e.

reading before after
armed body after kernel:ready stored body loader's body
engine version history for the name loader's, then stored loader's, loader's
record-triggered run, executed steps start, then the stored body's notify node start, then the loader's notify node
getShadowedFlows() for the name armed package, shadowed package (indistinguishable) armed package, shadowed runtime-authored row
boot audit line "package … is ARMED and package … is shadowed" "package … is ARMED and a runtime-authored row (sys_metadata) is shadowed"
GET /api/v1/automation/NAME 200, stored body 200, loader's body
execution view and GET /api/v1/meta/flow entry stored body, under the package's provenance loader's body
armed body after metadata:reloaded stored body loader's body
GET /api/v1/meta/flow/NAME (by-name) 200, stored body under the package's provenance unchanged; see Acceptance notes

Deviations: please confirm

  1. The precedence rank flips inside a held name. The claim scoped flow-precedence.ts to exposing the existing decision, without re-shaping it. Measured on the base:
  2. The pending release note .changeset/20864-precedence-loader-set.md is corrected. One clause is removed: "a flow authored in the deployment wins over the packaged flow of that name (ADR-0005)". This PR makes that clause false, and the note has not shipped yet. check-empty-changeset stays red by design for a deliberate correction of somebody else's pending note. Confirmation requested here; do not restore it from the base.
  3. ADR anchors, outside the claimed file list (AGENTS.md Prime Directive 13):
    • a new anchor for flow-precedence.ts (ADR-0126, ADR-0048);
    • ADR-0126 plus one invariant paragraph added to protocol.ts's anchor.

Tests at 22d8d3593e

  • pnpm --filter @objectstack/service-automation exec vitest run: 160 files, 2002 tests passed. Full run at 97bd52e852; since then only one test title changed, and the six precedence and sync files were re-run at 22d8d3593e (43 passed).
  • pnpm --filter @objectstack/metadata-protocol exec vitest run: 194 files passed and 3 skipped; 2879 tests passed and 19 skipped. protocol.ts is unchanged since that run; the new file and the tenant-authored suite were re-run at 22d8d3593e (21 passed).
  • pnpm --filter @objectstack/metadata-protocol --filter @objectstack/service-automation --filter @objectstack/dogfood run typecheck: clean. Service-automation's typecheck was re-run at 22d8d3593e; its check:test-typecheck reports 0 errors.
  • Dogfood, isolated project, at 22d8d3593e: flow-shipped-name-stored-row-boot (new, 6 cases), plus flow-provenance-server-held, automation-authoring-doors-durable and packaged-flow-write-door-parity. 31 passed.
  • New unit pins:
    • service-automation/src/flow-sync-one-precedence.test.ts, 7 cases: both syncs, held name, two packages, unheld duplicates, tear-down.
    • metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts, 9 cases: hydration, both faces, name-only judgement, controls for an unheld flow and an overlay-regime type.
  • 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 and 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) #20864 pins that encoded the runtime-first rank are updated to the sealed-base direction.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 77 families at 22d8d3593e, and --ran accounts for all 77 with exit codes.
    • 76 exit 0.
    • check-empty-changeset --base origin/main exits 1: deviation 2, red by design.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 unrelated packages had no dist/). After building them it exits 0.
  • pnpm lint (the whole repo, eslint . --no-inline-config) exits 0 at 22d8d3593e.

Ablations

Each ablation ran after the fix was committed. Every leg went through scripts/ablation-replace.mjs: the anchor hit exactly once, the blob changed, and the restore was proven by blob equal to HEAD and an empty git diff HEAD.

Unit legs (subjects imported from src/):

leg what was removed red
A1 the kernel:ready sync's precedence 3
A2 the metadata:reloaded re-sync's precedence 2
A3 the rank flip, put back to runtime-first 10
A4 the hydration graft, put back for flow 2
A5 the stored-row half of the view filter 4
A6 the registry half of the view filter 1

Dogfood legs (the dogfood suite resolves dist/, so each leg ran mutate, rebuild, ablation-dist-preflight marker present, run, restore, rebuild, --absent and tree clean):

  • D1, the view's stored-row half removed. The arming and execution pins go red (2 failed, 4 passed), and the receipt pin stays green. That leg's rebuild exited 1 only at the declaration step, on the now-unused binding; the JS bundles carried the marker (preflight: present in 2 built files).
  • D2, the rank put back to runtime-first. Only the receipt pin goes red (1 failed, 5 passed). The boot pull arms and records the stored row, and the sync then arms the loader's body, so the receipt no longer describes what is armed.

Acceptance notes

  • By-name door. GET /api/v1/meta/flow/NAME still serves the stored body under the package's provenance for a shipped name that has a stored row (measured after: 200, the stored body). It now disagrees with the list, which serves the loader's body.
  • Other flow readers of the served list. Every getMetaItems reader of flow inherits the list change, by source reading: GET /meta/diagnostics?type=flow, findReferencesToMeta sources of type flow, and the package export sweep in runtime/src/domains/packages.ts. For a shipped name with a stored row they now see the loader's body.
    • Execution-view readers: the automation flow syncs (the subject), and the automation connector read, which is untouched because the rule is scoped to flow.
  • Who writes the receipt. Only the boot pull writes the shadowing receipt. The two syncs resolve to the same winners by the same decision and log the collision warning when they meet a contested name, so a name two packages ship is warned at the pull and again at each sync. The re-sync does not refresh receipts after a runtime change; that was already so on the base.
  • An unheld name listed twice by the view. Two package-bound tenant rows of one name now resolve by arrival order at the syncs, as at the boot pull; the syncs used to arm the last one listed.
  • Stale ADR-0005 citations, out of surface and not changed:
  • Organization-scoped rows stay out of reach, pinned. The boot's metadata_org_scoped_unhydrated warning names such a row. For a shipped name its "will NOT bind its triggers" clause is true of the row only; the loader's flow stays bound.

Generated by Claude Code

… at both boot steps

Red on the base: after kernel:ready the armed body is the stored row's,
and the receipt renders both contenders as the package.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…oth boot steps; a stored row of a shipped flow name is shadowed, never armed

The kernel:ready sync and the metadata:reloaded re-sync resolve the
protocol's flow view through the same precedence decision the boot pull
uses, with the engine's loader's-set reader. Within a name the loader's
set holds, the loader's entry is armed (ADR-0126 section 2: a managed
package's flow is sealed). The hydration registers a stored flow row
without the artifact's envelope, and the flattened view serves a shipped
flow name from the loader's entries alone, so the receipt reports the
stored row as its own contender.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
… entry no longer states the retired direction; anchor ADR-0126 at the two seams

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/service-automation, touching 11 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via AutomationServicePlugin (symbol, a top-level class))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 — 14 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 bee75cebe63889f93fa1e6dc358cd8c5f7b9a928 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json bee75cebe63889f93fa1e6dc358cd8c5f7b9a928

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs bee75cebe63889f93fa1e6dc358cd8c5f7b9a928 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 22d8d3593eb561c3f7e5a39bca691f2855c616e3
Local-runs: none

This is the record of record for PR #20942 at 22d8d3593e, card #20913 (the kernel:ready sync re-arming a stored row's body over the loader's for a packaged flow name), Residual 2 of the PR #20880 record (5914326577) on #20864, under the #20761 ruling (5904938166, rule 1).

Inputs:

Check-runs on 22d8d3593e, read after convergence and collapsed latest-per-name (a background poll of the commit's check-runs, no local run): 35 runs, 31 success, 3 skipped (Build Docs and Console Pin Gate path-filtered, Packed-tarball smoke (opt-in) opt-in), 1 failure. All seven required contexts are success: Lint & Repo Gates (which carries check:adr-anchors over ① (f)), TypeScript Type Check (and its four sub-jobs), Test Core (and all six shards), Dogfood Regression Gate (and all three shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check PR Size, Spec property liveness, Dogfood Verify CLI, the claim, single-writer and part-of guards, Check Documentation Links and the labelers are success. The one red is Check Changeset, the deliberate-correction class, judged in ① (e); no other run is red.

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 protection envelope are named abstractly here, and no seeding step is written.

① Derived judgments

(a) One precedence decision — RIGHT. No path registers a flow body at boot or on reload outside it.

  • plugin.ts:2071-2077 is the one private helper, resolveFlowContenders: resolveFlowPrecedence(items, ctx.logger, reader), where reader is a closure over the engine's own packagedFlowOwner when the engine exists and undefined otherwise. Its three callers are the boot pull (:1027), the metadata:reloaded re-sync (:2116) and the kernel:ready sync (:2167), and the plugin's only three registerFlow calls (:1034, :2119, :2169) each iterate that helper's winners. The re-sync's tear-down reads the resolved names (freshNames, pinned: a name that leaves the view is still withdrawn). Repo-wide, the only other registerFlow callers are the runtime's automation authoring doors (runtime/src/domains/automation.ts:1565, :1617), which register the body the door admitted, refuse a held name as a locked base since access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 / fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853, run at request time and write no receipt: not a boot step and not a path that can arm a stored row under a shipped name.
  • The reader is the same one 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) #20864 routed: engine.packagedFlowOwner, fed by setPackagedFlowSource(packagedFlowReader(ctx)) at init() (plugin.ts:653, :464-473), which asks the protocol's packagedArtifactOwner({ type: 'flow', name }) (protocol.ts:14608-14613), through lookupArtifactItem to SchemaRegistry.getArtifactItem (registry.ts:3920), resolved at question time. The undefined branch (no engine) is the fail-closed answer the fix(service-automation): boot-time flow precedence classifies contenders by the loader's set #20880 record judged: nothing packaged.
  • Only the boot pull writes the receipt (recordFlowShadowing, :1037); the syncs resolve the same names to the same winners by the same decision and warn on a contested name. That holds because the protocol's view no longer lists a stored row under a shipped name (c), so a held name has one contender in the view and the boot pull's receipt describes what is armed after both syncs. Measured by the dev over two cold boots (engine version history "loader's, loader's"; the record-triggered run executed the loader's step) and pinned in flow-sync-one-precedence.test.ts (held name, two packages, unheld duplicates, tear-down) and the dogfood pin. Ablations A1 / A2 red as predicted.
  • One disclosed, pre-existing shape, not the card's: an unheld name two package-bound tenant rows share is listed twice by the view but once by the registry's bare slot, so the syncs arm the first listed (arrival order, the same rule the boot pull applies) with no receipt. Before, they armed the last listed. No packaged contender is involved, so no receipt can misreport a package there. Acceptance note, disclosed.

(b) The rank flip, the dev's open question 1 — EXECUTION of the direction, not a new product decision. Option A is right; confined exactly as claimed.

  • What moved: flow-precedence.ts:201-202, precedenceRank returns 0 for package, 1 for runtime; on the merge-base it was the reverse. Nothing else in the comparator moved: rule 2 (lexicographic package id) still applies within package only (:286-289), and full ties keep arrival order (:291).
  • Why it is confined: classifyContender (:118-127) returns package only when the loader's set holds the name AND the entry passes the registry's own per-entry artifact test; otherwise runtime. So a package contender exists only inside a held name, and the rank decides exactly one thing: inside a held name, the loader's entry beats a stored row. The two other cases are byte-identical to the merge-base by the comparator's unchanged code: two packages of one bare name are both package, tie on rank and order by id (pinned, "alpha wins over beta", both listing orders); a runtime row under a name no package ships, alone or beside a second runtime row, is all runtime, ties on rank and keeps arrival order (pinned in flow-precedence-loader-set.test.ts and the sync suite's unheld-duplicates case).
  • Why the direction already decides it. Direction bullet 1: "For a name the loader's set holds, the loader's body is what is armed." Bullet 2: the stored row "appears in the shadowed list and the receipt, with its own provenance. It is not merged under the package's stamps." The dev measured (and I confirm from the merge-base's hydration) that the base rank armed the loader's body only because the stored row wore the package's envelope, so both contenders ranked package and arrival order decided. Once bullet 2 strips the envelope the stored row ranks runtime, and a runtime-first rank arms it, which is the opposite of bullet 1 (dogfood ablation D2: the receipt pin goes red because the boot pull records the stored row armed while the sync arms the loader's). The two bullets cannot both hold under runtime-first; package-first inside a held name is what they entail.
  • Why ADR-0005 does not bind flows here. ADR-0005's own whitelist marks flow ❌ ("carry execution side-effects… a deployment, not an overlay"); its RUNTIME READ block ranks an overlay over the artifact default for overlays its write door admits, and for flow that door admits none (ADR-0126 §1.1 tier B, 403 NOT_OVERRIDABLE; ruling rule 2: a write to a held name is refused as a locked base). ADR-0048 §1.5 / §3.4 route "a write with no real package provenance" to ADR-0005's precedence; ADR-0126 §2 / §3 (accepted by merge, amended and re-homed by ADR-0131 D6) close that path for Regime C: "⛔ Never silent override, never an overlay read path", and ADR-0131 D6: no door edits a managed definition. So the reversal of 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 reading was recorded in accepted ADRs before this PR; Prime Directive [WIP] Add Chinese version of the documentation #13 is satisfied because the ADRs carry the decision and the code now points at them (the module header at :17-45, 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 file header at flow-name-shadowing.test.ts:19-28, which itself asked for exactly this re-argument, and the new anchor). The fix(service-automation): boot-time flow precedence classifies contenders by the loader's set #20880 record already read the Regime C outcome as "the loader's body stays armed" (its ③ finding 2). Option C, a maintainer round trip, is not owed: the maintainer's ruling on automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761 rests on the same two texts. Option B does not deliver bullet 2.
  • The collision warning now says which rule armed the body and names the remedy for the sealed case ("clone under a new name, or switch the packaged flow off", :298-311), which is ADR-0126 §7.1 / §7.2's sanctioned pair. Right, and pinned.

(c) The protocol side — RIGHT, scoped to flow at every branch, and organization-scoped rows are out of its reach by construction.

  • The two faces share one body: getMetaItems (protocol.ts:7875) and getMetaItemsForExecution (:7912) both call readFlattenedMetaItems (:7926), which is why the list an operator is served and the list the engine binds from cannot disagree. Two filters were added there: the registry half at :8065 (isStoredFlowEntryOfShippedName: a shipped flow name whose entry fails the artifact test, that is, the hydrated bare row) and the stored-row half at :8212-8214 (mergeable excludes a row whose name isShippedFlowName). The predicates (:14567-14584) gate on the canonical flow type first and ask packagedArtifactOwner by NAME, so a row's own bytes decide nothing (pinned: a row bound to the shipping package, or one whose body claims the package's stamps, is judged by name alone).
  • The hydration (:15845): the artifact's envelope is withheld for flow only; every other type keeps it (the view overlay control keeps both its envelope and its overlay). hydrateOverlayIntoRegistry is the one mint door for all three producers of registry rows, boot loadMetaFromDb (:23328), the flattened read's own hydration (:8289) and the write-through (:16080), so no producer can graft the envelope back onto a stored flow row. The row still reaches the registry, tenant-marked, under the bare key, which is what puts it in front of the boot pull as a runtime contender and into the receipt. Ablations A4, A5, A6 and D1 red as predicted.
  • Organization-scoped rows: organizationIdForMetaRead('flow', o) is undefined because flow declares no org override (metadata-core meta-write-org-scope.ts:184-190), so orgRecords is empty for flow (:8140) and no org row ever enters the overlays list or the merge; and the hydrator returns before registering any org row (:15820). The new code cannot reach one; pinned over a cold boot (dogfood: the loader's body, no receipt, the row not hydrated).
  • Other readers whose behaviour moves, all served-face readers of the flow list, each now serving the loader's body for a shipped name with a stored row: the REST GET /meta/flow list door, getMetaDiagnostics (:7099, the diagnostics door), findReferencesToMeta with flow as a source type, the runtime's package export sweep (runtime/src/domains/packages.ts:2235), and every SDK, MCP or CLI client of the list door. Each is right: an export of a managed package should carry the package's definition; diagnostics and reference sites that read the sealed body agree with what dispatches; none of them is an authoring door, and ADR-0126 §2 admits no overlay read path for a flow. On the execution face the only flow reader is the automation plugin's own read (plugin.ts:2028); the connector read is untouched because every branch is type-scoped. Nothing outside flow is touched (pinned by the view controls, and by the type gate in each predicate).
  • Two source readings the dev did not name, neither blocking: (i) a disabled package. listItems drops a disabled package's entries (registry.ts:4117-4130) but getArtifactItem does not consult isPackageDisabled, so for a shipped name of a disabled package that also has a stored row, isShippedFlowName still answers the package: the flattened view now omits the name entirely (before, it served the stored row), while the boot pull arms the bare stored row as a lone contender with no receipt (before, the sync armed it). The arming outcome is unchanged, the served list changes, and the fate of such a row is feat(metadata-core,metadata-protocol,objectql,plugin-security): the sys_metadata family goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206's. Unmeasured, reach needs a disabled package plus an at-rest row under its name. Residual 1. (ii) the registry-half filter asks the registry per flow entry (a package-scoped scan per item, flow only). Cost, not correctness; not measured, not blocking.

(d) The receipt — the shape is published and unchanged; only its values move for the defective case.

  • FlowShadowingRecord and FlowContender are barrel-exported types of @objectstack/service-automation (index.ts:44-45); engine.ts is not in the diff, so the shape is byte-identical. For a held name with a stored row the values now read armed package / shadowed runtime where the base read the package twice.
  • Where it surfaces: the plugin's bootstrap audit line (plugin.ts:1231-1238, its parenthetical now points at the collision warning instead of citing ADR-0005); the CLI os serve startup banner (cli/src/commands/serve.ts:6972 into utils/format.ts:1303-1310), a stderr diagnostic with no JSON face, which still says "(ADR-0005 overlay precedence…)" (the dev's finding 3, judged in ③); and getFlowRuntimeStates() rows, which carry armedFrom / shadowed as engine-added fields beyond the spec's FlowRuntimeState (spec/src/contracts/automation-service.ts:564-610 declares neither; engine.ts:4482-4492 adds both), served by GET /api/v1/automation/_status. That undeclared pair predates this PR (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 / CLI startup banner does not surface flow-name shadowing, though it already reads the rows that carry it #12028) and is not widened here. So: no public-surface change in shape; the changesets state the value change. Right.

(e) The changesets and the deliberate correction, the dev's open question 2 — RIGHT; this record confirms the correction, sentence by sentence.

  • The corrected note is .changeset/20864-precedence-loader-set.md (pending, @objectstack/service-automation minor, not yet shipped). The removed clause, quoted: "a flow authored in the deployment wins over the packaged flow of that name (ADR-0005)". This PR makes it false: inside a name the loader's set holds, package now ranks before runtime (flow-precedence.ts:201-202); outside one no contender is package, so the clause never described anything there. Removing it is right, and the sentence it sat in still parses.
  • The remaining text, sentence by sentence, at the head: the title line (precedence takes which same-named flow is the packaged one from the loader's set, not from the bodies' own provenance): still true. "Clause-②: yes (widening)": still true of that PR (two signatures widened). "When several flow definitions share one name at startup, the automation plugin arms one of them and shadows the rest.": true. "Which contender counts as the packaged one is now the answer of the set of flows a managed package's loader registered.": true, untouched by this PR. "That is the same answer the ADR-0126 §7.3 subflow guards, the arming gate and the activation switch read since automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761.": true. "The package provenance a flow definition carries is kept for display only.": true. Bullet 1 (the two signatures take the reader; the plugin passes the engine's own packagedFlowOwner): true, now through resolveFlowContenders, still the engine's reader. Bullet 2 (a stamped body of an unheld name ranks as the deployment's; the record no longer names that package): true, classification is untouched. Bullet 3 (no reader, nothing packaged): true. Bullet 4 (two deployment-ranked contenders keep listing order; package id orders packaged contenders only): true, the tie code is unchanged. Bullet 5 ("A startup whose registry the package loader and the stored-flow hydration filled arms the same flows as before: those entries already agree with the loader's set."): still true as that PR's own delta, which is what a changeset states (the M3 reading it was written from); it is the one sentence whose truth now depends on that frame, and the 20913 note is what states the moved case in the same release, so no further correction is owed. The migration line (pass the reader; without it no contender ranks as packaged): true.
  • Check Changeset red: the job log shows exactly one refusal, the foreign-changeset rule on that file, rendered with both classes, and "No empty-frontmatter changeset introduced". Nothing else in that job is red. For the seat's landing check (SKILL.md's three conditions for a red by design): the source self-describes the DELIBERATE CORRECTION class (scripts/check-empty-changeset.mjs, FOREIGN_CORRECTION_REMEDY); the gate runs from pr-automation.yml on pull_request only, not on merge_group; the PR body's Deviation 2 names the note and the gate, and this record, once posted on the PR, names both again.
  • New changeset 20913-flow-stored-row-shipped-name.md, @objectstack/metadata-protocol patch, Clause-②: no: every sentence is delivered at the head and pinned (Regime C, the two places, the hydration, the list "both faces" naming the list door and the execution view, the row neither deleted nor rewritten nor refused and reported as shadowed at startup, every other type and every unshipped name listed as before, the by-name read unchanged). It discloses finding 1 in its last sentence. Package and level right (a bug fix in a released package, no accept set moves, no public member added). Disclosure: doors only, no field spelling.
  • New changeset 20913-flow-sync-one-precedence.md, @objectstack/service-automation patch, Clause-②: no: every sentence is delivered and pinned (the two startup steps and the reload through one decision with the engine's reader; the sealed base armed and the stored row shadowed, the ADR-0126 §2 customization pair named; "This replaces the earlier direction" stated plainly; the receipt naming the row as runtime-authored and the warning naming the rule; unshipped names unchanged; two packages by id). One precision point, not an error: the sentence opening "Names no managed package ships are unchanged" appends the two-packages case, which is a shipped name; the appended clause is separately true. The migration line for direct callers of resolveFlowPrecedence states the one moved outcome. Package and level right. Disclosure: API names and doors only.
  • Across the PR body, both changesets, the report and every added test title: doors, roles, codes and statuses only; no request-body, header or field spelling; no seeding recipe (the body says a row "was placed at rest … between two boots"). The dogfood pin's code is the one place the placement is spelled, as any cold-boot pin must; that surface is outside the discipline as the claim words it (PR body, changeset, test title), and is left to the seat in ③. No model identifier in the five commits (model-free trailer pair on each), the PR body (session-URL footer) or the changesets. No tracker number in runtime prose.

(f) The two scripts/adr-anchors/*.json edits — RIGHT, and compelled.

  • Prime Directive [WIP] Add Chinese version of the documentation #13 requires anchoring load-bearing spots of an ADR's decision, one JSON per anchored path, named for the path, no index; scripts/adr-anchors/ is not a governed surface. The new packages__services__service-automation__src__flow-precedence.ts.json lists ADR-0126 and ADR-0048, both real records, both cited in the file (:17-45); its invariant states the rank, its scope (inside a held name), the loader's-set rule, the id order for two packages and the one-function rule for all three steps, all true at the head. The protocol.ts anchor adds ADR-0126 and one invariant paragraph naming the hydration's withheld envelope and the both-faces filter with the two predicates by name; true at the head, and the id appears in the file. The deviation outside the claimed file list is the directive's own instruction, disclosed.

Surface inventory: no route, schema, query set, status code or exported signature changes; two exported types unchanged in shape; one barrel-exported function's outcome moves for exactly the held-name-with-stored-row case; two patch changesets plus one pending note corrected; no governed path.

② Semver level

The PR body's line 2 reads Clause-②: no, as the claim did, and both changesets are patch. RIGHT.

③ Boundary flags

Implemented-by: claude/issue-20913-sync-one-precedence
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset is red by design: a deliberate correction, confirmed by the record

domain:cli seat · session_01VvcEokUG1tvVxkceYfR5XB · 2026-09-30T21:33Z

  • The gate: Check Changeset (.github/workflows/pr-automation.yml, node scripts/check-empty-changeset.mjs --base). Its one refusal is the foreign-changeset rule on .changeset/20864-precedence-loader-set.md.
  • The cause: this PR removes one clause from that PENDING note, "a flow authored in the deployment wins over the packaged flow of that name (ADR-0005)", because this PR makes it false. Inside a name the loader's set holds, the loader's entry now ranks first.
    • This is the DELIBERATE CORRECTION class that the gate's source names (FOREIGN_CORRECTION_REMEDY: "do NOT restore it -- say so on the PR and get it confirmed").
    • The workflow's own text says: "LEAVE THIS CHECK RED … The red is the point".
  • Confirmed: the contract-review record 5920108821 on this head names the note, quotes the removed clause, and judges every remaining sentence of it as still true (① e).
  • Queue: Check Changeset is not a required context, and pr-automation.yml triggers on pull_request only, never on merge_group. Every other check on this head is green or an expected skip. ⛔ The note is not restored from the base.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 21:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 75519e1 Sep 30, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20913-sync-one-precedence branch September 30, 2026 22:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…oyment has not installed (objectstack-ai#20863) (objectstack-ai#20959)

Fixes objectstack-ai#20863
Clause-②: no (narrowing)

## What this does

A flow saved through the metadata door (`PUT /api/v1/meta/flow/:name`)
may name, as its base, the package it belongs to. When that id was a
package this deployment has never installed, the door answered `200`,
stored the flow live, and served the binding back: a flow bound to a
package that does not exist. It now answers `422
WRITABLE_PACKAGE_REQUIRED`, and nothing is written, served or
registered.

- **One rule, extended in place.** The refusal lives in
`tenantAuthoredWriteRefusal`
(`packages/metadata-protocol/src/protocol.ts`), the one shared function
every flow write door asks, which PR objectstack-ai#20853 established. There is no
second check. The rule gains a named-base arm between the locked-base
lock and the provenance check. `saveMetaItem` already handed the rule
the base it names, so its code is unchanged; only its comment at the
hand-off says so.
- **"Installed" is the set the metadata write path already resolves a
base against.** The arm asks `resolveWritePackageScope`, the registry's
package read that the runtime authoring gate uses for its package
closure. It is not the loader's managed set alone: a tenant's own
writable base, created through the package door and rehydrated from the
package store at boot, is installed and ships no flow. No new registry
read and no second list of packages.
- **Whatever the definition carries.** Both branches that used to admit
the save now refuse it: a definition with no provenance of its own, and
one whose provenance names that same missing package.
- **Order.** A shipped flow is still refused as a locked base first
(objectstack-ai#20679's check, reused). With the operator's writable-types hatch open,
the lock admits a shipped flow, and a missing base is still refused: the
hatch unlocks a type, never a binding.
- **Scope.** `flow` only, as the objectstack-ai#20761 ruling's rule 5 requires. Every
other type keeps its old handling. The `/automation` create, update and
clone doors name no base, so they do not move. The two server-stated
rewrites (stored-metadata migration and package duplication) are not
judged by the rule, as before.
- **The stored-row sentinel is not a package.** A save naming it is a
package-less save, admitted as before.

## Hypotheses from the dispatch, measured

- **H1 held.** Measured on `origin/main` `31c39964fc` against the
unmodified rule, with an environment id and without. A definition with
no provenance returns `null` at the "body not code-shipped" branch. A
definition whose provenance names the same missing id returns `null` at
the "named base equals the stamp" branch. The ADR-0070 D1 gate further
down then admits the save, because its writability predicate reads an
unregistered id as a writable authoring workspace. At the door, the
ablation's mutated leg below re-measured it on the showcase: `200`,
state `active`.
- **H2 held, with one qualification.** The reader exists:
`resolveWritePackageScope` (registry `getPackage`), reused inside the
rule. The qualification is that its `undefined` covers both "the
registry does not hold this id" and "the registry cannot be read" (a
registry with no package read at all, or one that throws). So on such a
registry a named base is refused: the fail-closed direction, the same
one the automation engine takes with no loader's-set reader. Every real
composition's `SchemaRegistry` has the read. This is written down in the
rule's docblock.
- **H3: `WRITABLE_PACKAGE_REQUIRED` / 422.** Ledger row:
`packages/spec/src/api/error-code-ledger.zod.ts:634`, in the
`@objectstack/metadata-protocol` block (line 590). No code is minted.
ADR-0070 D1 decided this code for exactly this condition: a runtime
create whose resolved base is missing or read-only. Its remedy is the
one this caller needs: choose or create a writable base, or name none.
`INVALID_METADATA` (line 610, same block) was rejected because the
definition may be perfectly valid; what is wrong is the base the request
names, not a key in the body. The sentence is new, because the D1
emitter's sentence says "read-only", which is false for a package that
does not exist. The refusal carries the refused id and the ADR-0070 docs
pointer, as that emitter's does.
- **H4 measured, one topology NOT MEASURED.** The in-repo environment
kernel is the standalone stack (`createStandaloneStack`, environment id
`env_local`). One-shot boot, not kept as a file, through the kernel's
protocol service: both definition shapes answered
`WRITABLE_PACKAGE_REQUIRED/422`, 0 metadata rows, registry item absent.
The two controls saved `active`: no base named, and a base installed
through `installPackage`. The unit pins also run every case with an
environment id. The cloud's per-environment kernel manager is not in
this repository: NOT MEASURED.
- **H5 held and pinned.** Unit level: the store's insert and the
registry's `registerItem` are never reached, for published and drafted
saves, on both topologies. Door level: 0 `sys_metadata` rows under the
name, the metadata read answers `404`, and the automation read answers
`404` (the engine never armed it).

## Pins

-
`packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts`,
a new describe block with 7 cases: the refusal across 5 definition
shapes and both topologies, plus the plural type spelling; nothing
written or registered on published and drafted saves; three controls (no
base or the sentinel passes, an installed base passes, a shipped flow is
a locked base first whatever base is named); the hatch case; and another
type left untouched. The rule's registry double now serves the
registry's package read.
- **One door-level pin** on the showcase host-config boot, in the
existing
`packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts`:
one `it`, so no second boot. Triage's door-level controls are the cases
already in that file: a customer flow with no base saves, one in the
tenant's installed base saves, and a shipped flow is refused as a locked
base.
- **Fixtures re-judged, because they saved flows into a package their
registry never held.**
- `protocol-publish-drafts-advisories`,
`protocol-publish-drafts-closure` and
`protocol.publish-item-rebind-announce` now declare their base
installed, with no namespace and no dependencies. The prefix pre-flight
and the closure are unchanged, and no assertion moves.
- `protocol.package-closure-gate`'s "narrows nothing when the registry
cannot produce the written package" pinned the very branch this change
shuts for a flow save. It now reaches that state the way it still
arises: a draft promoted after its package left the registry, beside the
installed control, which reports.

## Evidence

All runs are on HEAD `e201d770b2` unless noted.

- metadata-protocol, full suite at `f051bba0e2`: 194 files passed and 3
skipped; 2886 tests passed and 19 skipped; exit 0. `typecheck`: exit 0.
The one file changed after that,
`protocol.tenant-authored-write.test.ts`, re-ran at `e201d770b2`: 19/19,
and `typecheck` exit 0.
- Consumers, because the wire answer changed:
  - objectql full suite: 349/349 files, 6812/6812 tests.
  - runtime full suite: 300/300 files, 5000 passed and 5 skipped.
  - rest full suite: 245/245 files, 4878 passed and 114 skipped.
  - These three ran at `eb25706394`, before the objectstack-ai#20942 merge.
  - At `f051bba0e2`:
- dogfood, 4 flow files (this pin's file, objectstack-ai#20942's
`flow-shipped-name-stored-row-boot`, the clone door and the durable
doors): 32/32.
    - runtime, 6 automation and `/meta` files: 312/312.
    - objectql's publish-conformance file: 15/15.
- `typecheck` exit 0 for objectql, including its test-layer check, and
for dogfood.
- **Red/green ablation of the new arm** at `e5914e3f2c`, through
`scripts/ablation-replace.mjs`, whose restore is armed on exit:
- The mutation made the arm's condition unsatisfiable. On disk: marker
count 1, guard text count 0. Both dists that carry the arm were rebuilt,
`@objectstack/metadata-protocol` and `@objectstack/rest` (rest bundles a
copy). `ablation-dist-preflight` found the marker in both dists.
- Mutated leg: the unit file read 3 failed, 16 passed of 19 (the three
refusal cases). The dogfood file read 1 failed, 13 passed of 14: the new
pin, answering `200` with state `active`.
- Restore: the blob equals HEAD, `git diff HEAD` is empty, and `git
status --porcelain` is empty. After rebuilding both, `--absent` passed
for both dists. The files read 19/19 and 14/14.

## Gates

- `node scripts/pm/dispatch-gates.mjs --commands` derived 68 commands at
`e201d770b2` from a tree that was not stale. All 68 were run, and every
one exited 0.
- `--ran` reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
- Two first readings were not measurements and were re-run:
- `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3),
because 8 packages outside this closure had no `dist/`. They were built,
and it exited 0.
- `check:engine-double-contract` refused an `update` double this PR had
added to the rule's test. The double was dropped rather than growing the
pinned ledger, and the gate exited 0.
- `check:adr-0087-registration`: one declared-breaking changeset,
disposition `not-required (no-migration-prescription)`.
- `pnpm lint`: a proven narrowing, not a full run.
- (1) Population: all 8 changed `.ts` files are under `packages/**`,
which the `packages/**/*.{ts,tsx,mts,cts}` and `**/*.{ts,…}` blocks of
`eslint.config.mjs` lint.
- (2) `eslint --no-inline-config --format json` over those files: 8
files, 0 errors, 0 warnings, at `e201d770b2`.
- (3) Invariance: the config never enables type-aware linting
(`eslint.config.mjs` lines 326-328), so a verdict on an untouched file
cannot move.
- CI: not awaited.

## Changeset

`.changeset/20863-orphan-package-binding-refused.md`:
`@objectstack/metadata-protocol` `minor`, **BREAKING** under the
launch-window convention, following PR objectstack-ai#20907's shape. It carries one
ADR-0087 marker and a line telling the caller what to send instead: an
installed base, or no base. The dogfood package is private. The objectql
change is a test file, which ships nothing.

## Declared deviations

- **Outside the claim's file surface:**
`packages/objectql/src/publish-package-drafts-response-conformance.test.ts`,
+4 lines, in a separate commit (`eb25706394`) that can be dropped on its
own.
- Its harness staged flow drafts into a package its real registry never
held, so any implementation of the ruling turns 5 of its cases red. It
now installs that base, with no namespace. No assertion moves, and the
file reads 15/15.
  - The claim did not name it. It is declared here, not taken silently.
- The door-level pin went into the existing objectstack-ai#20761 dogfood file rather
than a new file, to avoid a second showcase boot in CI.
- `main` was merged twice (no rebase). The second merge brought objectstack-ai#20942
(`75519e1c0a`), which edits `protocol.ts` near this rule. Git merged it
cleanly, and both changes are present.

## Acceptance notes (noted, not filed)

- For every type other than `flow`, the metadata door still stores a row
bound to a package id no installed package holds. The ADR-0070
writability predicate reads an unregistered id as a writable authoring
workspace. This is kept deliberately: the objectstack-ai#20761 ruling's rule 5 leaves
other types unchanged. Carrier: none.
- A host with no package store loses a runtime-created base from the
registry at restart. That is an existing degradation, and
`installPackage` states it loudly. After this change, a flow save naming
such a lost base is refused rather than stored bound to it. Not
measured. Carrier: none.
- `@objectstack/rest`'s built `dist/` carries its own copy of this
package's protocol code: the new sentence appears there, and rest lists
`@objectstack/metadata-protocol` as a devDependency. Observed while
scoping the ablation's rebuild. Not investigated further. Carrier: none.
- objectql's publish-conformance harness calls a registry method that
`SchemaRegistry` does not declare (0 hits in `registry.ts`), behind
optional chaining, so those two calls do nothing. This is a reading, not
measured. Carrier: none.
- The `protocol.ts` ADR anchor does not mention the new named-base arm.
It was not added, because the anchor file is outside the claim's
surface. The rule's docblock cites ADR-0070 D1 and ADR-0126 §2. Carrier:
none.

## NOT MEASURED

- The cloud per-environment kernel manager: it is not in this
repository. H4's in-repo environment kernel was measured, as above.
- Studio's round trip: objectui is not in this container. The
server-side refusal is what Studio receives.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…s the loader's body, as the list does (objectstack-ai#20946) (objectstack-ai#20994)

Fixes objectstack-ai#20946
Clause-②: no

## What

For a flow name the loader ships from a managed package, the by-name
read (`GET /api/v1/meta/flow/NAME`) now answers the loader's body. That
is the same body the flow list (`GET /api/v1/meta/flow`) and the
execution view have answered since objectstack-ai#20913. A stored row of that name is
no longer served by name as the package's definition.

`getMetaItem` in `packages/metadata-protocol/src/protocol.ts` now calls
the two predicates PR objectstack-ai#20942 introduced for the list, and adds no
precedence rule of its own:

- **The stored-row half, `isShippedFlowName`, judged by name.** The
active read does not adopt the environment-wide stored row of a shipped
flow name. The row's package binding and the body's package-provenance
stamps decide nothing.
- **The registry half, `isStoredFlowEntryOfShippedName`.** The registry
answers its bare slot first, and for a shipped flow name that slot holds
the hydrated stored row. That entry is not one of the loader's, so the
loader's entry is served.

The predicates are called, not edited. Only `getMetaItem` moves in
`protocol.ts` (+36 / -1 there).

## Why

- Triage direction `5920432754` on objectstack-ai#20946 (the interim): "the by-name
read serves the loader's artifact for a shipped flow name, using the
same predicates PR objectstack-ai#20942 introduces for the list and the execution view
… ⛔ No third precedence path. The by-name read calls the predicate the
list calls."
- ADR-0126 §2 (`flow` is Regime C): "⛔ Never silent override, never an
overlay read path". ADR-0131 D6: managed definitions are sealed.
- objectstack-ai#20761's ruling `5904938166`, rule 1: a body's package-provenance
stamps are display only.

What becomes of the stored rows themselves (keep, refuse, migrate)
belongs to objectstack-ai#15206. This PR does not decide it.

## Repro, before and after

Showcase composition on a database file, cold boot. Between two boots, a
stored row was placed at rest under a shipped flow name, with a body
that can be told apart from the loader's (its own label, one node
renamed). Two more rows were placed: an organization-scoped row under a
second shipped name, and an environment-wide row under a name no package
ships.

| Door or reading | `origin/main` `f6ccca4a44` | this branch |
|---|---|---|
| `GET /meta/flow/NAME`, shipped name with a stored row | 200, the
stored body, under the package's stamps | 200, the loader's body |
| the same door with a package scope | 200, the stored body | 200, the
loader's body |
| `GET /meta/flow`, the entry for NAME | the loader's body | the
loader's body (unchanged) |
| startup receipt for NAME | armed: package, shadowed: runtime |
unchanged |
| control: a shipped name with no stored row | the loader's body |
unchanged |
| control: a shipped name with an organization-scoped row only | the
loader's body | unchanged |
| control: an unshipped name with a stored row | the stored body |
unchanged |

## Pins

- **Unit:**
`packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts`,
10 cases. It uses a registry double with the real `SchemaRegistry` key
shapes, its `getItem` precedence (the bare slot first) and its artifact
lookup.
- A shipped name with a stored row answers the loader's body, both
before and after the row is hydrated.
  - By name and in the list, the shipped name answers the same body.
- The package-scoped read and the plural type spelling answer the same.
- A row bound to the shipping package, or one whose body claims the
package's stamps, is judged by name alone.
- Controls: an unshipped name keeps its stored row; a shipped name with
no row is unchanged; an organization-scoped row is out of reach; an
overlay-regime type keeps its overlay.
- **Dogfood cold boot:**
`packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts`,
8 cases.
  - By name, on both spellings of the door, the loader's body.
  - By name and in the list, one and the same body.
- The stored row is still reported as a shadowed contender, and the
loader's body is what is armed.
  - The three controls in the table above.
- The pin is a new file. `flow-provenance-server-held.dogfood.test.ts`
is not touched.

## Verification, at head `07843e6889`

- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2` (the whole package): 195 files passed, 3 skipped; 2896
tests passed, 19 skipped.
- `pnpm --filter @objectstack/metadata-protocol run typecheck`: exit 0.
`tsc --listFiles` includes the new unit pin.
- Dogfood, `vitest run` over four files: the new pin, PR objectstack-ai#20942's
`flow-shipped-name-stored-row-boot`, `flow-provenance-server-held` and
`automation-authoring-doors-durable`. 4 files, 35 tests passed. The
metadata-protocol `dist` carries the fix.
- `pnpm --filter @objectstack/dogfood run typecheck`: exit 0.
`--listFiles` includes the new pin.
- **Red before:** the dogfood pin against the `origin/main` build of
metadata-protocol gives 3 failed and 5 passed. The three failures are
the by-name cases; the store check, the receipt and the controls pass.

**Ablation.** The fix was committed first (`09f3a596bd`). Each leg ran
through `scripts/ablation-replace.mjs`, with its anchor hit once and a
blob change confirmed on disk. The unit pin imports `./protocol.js` from
source, so no rebuild was involved.

| Leg | What was removed | Result |
|---|---|---|
| A1 | the stored-row half | 6 failed, 4 passed |
| A2 | the registry half | 2 failed, 8 passed: the post-hydration case
and the list-agreement case |

Both restores were proven: the blob equals HEAD (`5d475cd667`) and `git
diff HEAD` is empty.

**Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands`
printed 74 commands for this tree. All 74 were run, each exit code
captured before any pipe. `--ran` reconciliation: 74 derived, 74 run, 0
NOT-MEASURED, 0 unrun.

- On the first pass, `check:dts-closure` and
`check:dual-build-cjs-loads` exited 1. Both named
`@objectstack/organizations` missing `dist/index.d.ts`, a package
outside this diff that was partially built in the shared local tree.
After `pnpm --filter @objectstack/organizations build`, both exited 0.
- The seven roster gates whose roster sits beside a path of this diff
were also run, all exit 0: `check-changeset-fixed`,
`check-published-list-mirrors`, `check:authz-resolver`,
`check:console-injection`, `check:error-code-casing`,
`check:i18n-stale-fill` and `check:published-readme-exports`.
- The head is 3 commits behind `origin/main` `7fa67dada3` (formula,
plugin-security, service-analytics and the PM fleet-write scripts). None
of those commits touches a path of this diff.

**Lint, a proven narrowing of `pnpm lint` (the repo-wide run is CI's):**

1. **Population, from eslint's own config:** of the 5 touched paths, the
config matches the 3 `.ts` files. The `.md` and `.json` files answer
"File ignored because no matching configuration was supplied."
2. **Count, from `--format json`:** 5 results. The 3 linted files have 0
errors and 0 warnings.
3. **Invariance:** `eslint.config.mjs` never enables type-aware linting
(every `parserOptions` is `ecmaVersion` and `sourceType` only, with no
`project`). The only other files it reads are
`scripts/slot-lookup-baseline.json` and
`scripts/query-options-erasure-baseline.json`, and this diff touches
neither. So the diff cannot move the verdict on any untouched file.

**NOT MEASURED locally, declared to CI:**

- Test Core shards, Temporal Conformance, the full Dogfood Regression
Gate and Dogfood Verify CLI.
- Build Core and the workspace type-check lanes.

## Deviations

1. **`scripts/engine-double-contract.pinned.json`, one generated row.**
The new unit pin's engine double has a `findOne`, so it routes through
`assertEngineFindOnePredicate`. `check:engine-double-contract` then
requires the coverage ledger to learn the file, and it prescribes
`--write`. The diff is exactly that one row. The file is outside the
claim's file list, and the gate compels it.
2. **The package-scoped spelling is changed too.** The dispatch's
mechanism hypothesis listed reads with a package id as unchanged.
Measured on `origin/main`, the package-scoped by-name read served the
stored body as well, because the stored-row lookup falls back to the
package-less row. The list applies the two predicates whatever the
package scope. Leaving this spelling out would have left the defect
reachable on the same door, so it follows the ruling's intent, and it is
pinned in the unit and dogfood suites.
3. **Two merges of `origin/main`.** Neither had conflicts. The net delta
against `main` is 5 files, +601 / -1.

## Acceptance notes

- **Unchanged, and named:**
- the strict draft read and the draft-preview arm (a draft is answered
as a draft, never under the artifact's envelope, and the list's preview
arm is equally unfiltered);
  - every other metadata type (both predicates gate on `flow` first);
  - flow names no managed package ships;
- organization-scoped flow rows, which this read never reaches because
`flow` declares no org override.
- **The metadata-service step of the by-name read is untouched.**
Measured on the showcase composition, it answers nothing for a shipped
flow name, an unshipped one or a stored one. The list's own
metadata-service merge is not filtered by the predicates either.
- **The pending note
`.changeset/20913-flow-stored-row-shipped-name.md`** ends with "The
by-name read, `GET /api/v1/meta/flow/:name`, is not changed."
- That stays true as that PR's own delta. This is the reading the objectstack-ai#20942
record applied to the 20864 note's bullet 5.
  - This PR's note states the change in the same release.
- It is not corrected here because it is outside the claim's file list.
The seat may choose to correct it.
- **The ADR anchor for `protocol.ts`** already lists ADR-0126. Its
invariant sentence names only the list, which is still true. It could
gain a by-name clause on its next touch. Carrier: none.

## Out-of-scope finding, for the seat to file

- **class b · the layered read door, `GET
/api/v1/meta/flow/NAME/layers`.**
- **What it serves:** for a shipped flow name with a stored row, it
answers its effective layer as the stored body, and the response's
provenance names the package.
- **Measured:** 200 both before and after this PR, on the cold boot
above.
- **Contract:** the method's own docblock says the effective layer is
"what `getMetaItem` would return". ADR-0126 §2 says "never an overlay
read path".
- **Why now:** after this PR it is the one read door for that name that
disagrees with the list and the by-name read.
- **Remedy shape:** the same predicate, so the effective layer takes the
code layer for a shipped flow name.
  - **Not done here:** this claim's region is `getMetaItem` only.
- Dedupe words: `meta flow layers effective stored row shipped name` ·
`layered read effective overlay flow regime C`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ts the loader's body as the effective layer, as the by-name read and the list do (objectstack-ai#21002) (objectstack-ai#21043)

Part of objectstack-ai#21002
Clause-②: no

## What

For a flow name the loader ships from a managed package, the layered
read (`GET /api/v1/meta/flow/NAME/layers`, and the deprecated layers
flag on the by-name door, which uses the same helper) now reports the
loader's body as the effective layer. That is the body the by-name read
(`GET /api/v1/meta/flow/NAME`) and the flow list (`GET
/api/v1/meta/flow`) have answered for that name since objectstack-ai#20946 and objectstack-ai#20913.
A stored row of that name is still reported, as a separate shadowed
layer of its own scope, and is never the effective layer under the
package's lock and provenance flags.

`getMetaItemLayered` in `packages/metadata-protocol/src/protocol.ts` now
decides its effective layer with the stored-row predicate PR objectstack-ai#20942
introduced and PR objectstack-ai#20994 reuses, `isShippedFlowName`, judged by name. It
adds no precedence rule of its own.

- **The predicate is called, not edited.** Only `getMetaItemLayered`
moves in `protocol.ts`: the effective-layer binding and its docblock
(+29 / -2 there).
- **The response shape is unchanged.** No key is added or removed. The
stored row stays where the layered answer already reports a stored row,
beside the effective layer, with its own scope.
- **The registry half needs no call here.** The code layer reads the
loader's set (`lookupArtifactItem`, blind to tenant-authored rows)
before the registry's bare slot, and a shipped name is one that set
holds by definition. So `isStoredFlowEntryOfShippedName` would add an
unreachable branch.
- **The lock and provenance flags are unchanged.** They already resolved
from the code layer first.

## Why

- Triage direction `5923270373`: "In `getMetaItemLayered`, the effective
layer for a shipped flow name is the loader's body, decided by the
predicate PR objectstack-ai#20942 / objectstack-ai#20994 put on the list and the by-name read. ⛔ No
fourth precedence path." And: "The stored row appears **as a shadowed
layer**, with its own provenance, never as the effective layer under the
package's lock flags."
- ADR-0126 §2 (`flow` is Regime C): "⛔ Never silent override, never an
overlay read path". ADR-0131 D6: managed definitions are sealed.
- objectstack-ai#20761's ruling `5904938166`, rule 1: a body's package-provenance
stamps are display only.
- The method's own docblock, and the spec's description of the layered
response, say the effective layer is what the ordinary by-name read
returns. For a shipped flow name that is now true.

What becomes of the stored rows themselves (keep, refuse, migrate)
belongs to objectstack-ai#15206. This PR does not decide it.

## Why this PR is `Part of`: the published-snapshot door does not follow

The triage expected the published-snapshot read to follow the effective
layer automatically. Measured, it does not.

- `GET /api/v1/meta/flow/NAME/published`
(`packages/rest/src/rest-server.ts`, about `:8413`–`:8425`) reads the
layered answer, but it picks a layer itself: when a stored layer is
present it serves that layer, and it never reads the effective one.
- Its dispatcher twin in `packages/runtime/src/domains/meta.ts` (about
`:1121`–`:1137`) has the same shape, by source reading. It was not
measured: the dogfood stack routes through the REST transport.
- So, with the stored row kept as a shadowed layer as the triage
requires, that door still answers `200` with the stored body, both
before and after this change.

The dispatch said to stop there and report, and not to edit that door in
this card. The measurement and the options are in the report on objectstack-ai#21002.
objectstack-ai#21002 remains open for that half.

## Repro, before and after

Showcase composition on a database file, cold boot. A stored row is at
rest under a shipped flow name, with a body that can be told apart from
the loader's. There is also an organization-scoped row under a second
shipped name, and an environment-wide row under a name no package ships.

| Door or reading | `origin/main` `2f2fa11d75` | this branch |
|---|---|---|
| `/meta/flow/NAME/layers`, shipped name with a stored row: the
effective layer | 200, the stored body, under the package's provenance
and package id | 200, the loader's body, same flags |
| the same answer: the stored row | reported as a separate layer,
environment scope | unchanged: reported, shadowed |
| the deprecated layers flag on the by-name door | 200, effective layer
is the stored body | 200, effective layer is the loader's body |
| `GET /meta/flow/NAME` | 200, the loader's body | unchanged |
| `GET /meta/flow`, the entry for NAME | the loader's body | unchanged |
| `GET /meta/flow/NAME/published` | 200, the stored body | **unchanged:
200, the stored body** (see above) |
| control: a shipped name with no stored row, layers | effective layer
is the loader's body, no stored layer | unchanged |
| control: the same name, published | `501 NOT_IMPLEMENTED` (this kernel
has no code/package store) | unchanged |
| control: a shipped name with an organization-scoped row only, layers |
effective layer is the loader's body, no stored layer | unchanged |
| control: an unshipped name with a stored row, layers | effective layer
is the stored body | unchanged |
| control: the same name, published | 200, the stored body | unchanged |

## Pins

- **Unit:**
`packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts`,
9 cases. It reuses the registry double of PR objectstack-ai#20994's unit pin: the real
`SchemaRegistry` key shapes, its `getItem` precedence (the bare slot
first) and its artifact lookup.
- A shipped name with a stored row: the effective and code layers are
the loader's body, the stored row is reported with its own scope, and
the package's flags stand. This holds before and after the row is
hydrated.
- The layered read, the by-name read and the list answer one and the
same body.
- The package-scoped read and the plural type spelling answer the same.
- A row bound to the shipping package, or one whose body claims the
package's stamps, is judged by name alone.
- Controls: an unshipped name keeps its stored row as the effective
layer; a shipped name with no row is unchanged; an organization-scoped
row is out of reach; an overlay-regime type keeps overlay-wins.
- **Dogfood cold boot:**
`packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts`,
8 cases, a new file.
- The layered door reports the loader's body as the effective layer,
under the package's flags.
- The stored row is still reported, as a shadowed layer of its own
scope.
- The layered door, the by-name read and the list answer one and the
same body.
  - The deprecated layers flag answers the same.
- Three controls: a shipped name with no stored row, an
organization-scoped row, and an unshipped name.
- `flow-shipped-name-by-name-read.dogfood.test.ts` and
`flow-provenance-server-held.dogfood.test.ts` are not touched.
- The published-snapshot door is **not** pinned. The file's header says
why.

## Verification, at head `39ed9ac48a`

`protocol.ts` and both pins are byte-identical between `5cdb27e44d` and
`39ed9ac48a`. The last commit adds only the changeset and the ledger
row.

- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2` (the whole package), at `39ed9ac48a`: 196 files passed,
3 skipped; 2905 tests passed, 19 skipped.
- `pnpm --filter @objectstack/metadata-protocol run typecheck`: exit 0.
`tsc --listFiles` includes the new unit pin.
- Dogfood, `vitest run` over four files: the new pin, PR objectstack-ai#20994's
`flow-shipped-name-by-name-read`, PR objectstack-ai#20942's
`flow-shipped-name-stored-row-boot` and `flow-provenance-server-held`. 4
files, 36 tests passed. The metadata-protocol `dist` carries the fix:
the guard's text is in 2 built files.
- `pnpm --filter @objectstack/dogfood run typecheck`: exit 0.
`--listFiles` includes the new pin.
- **Red before:** on the `origin/main` build of metadata-protocol, the
layered door's effective layer for the subject was the stored body. The
table above shows this.

**Ablation.** The fix was committed first (`d690943261`). Each leg ran
through `scripts/ablation-replace.mjs` and deleted the predicate clause
from the effective-layer binding. The anchor hit once, and the blob
changed from `6056394ec7a6` to `ed01c7ddc863`.

| Leg | Resolution | Result |
|---|---|---|
| A1, the unit pin | `./protocol.js` from source, no rebuild | 5 failed,
4 passed. The 5 are every shipped-name case; the 4 controls pass. |
| A2, the dogfood pin | the metadata-protocol `dist`, rebuilt after the
mutation | 3 failed, 5 passed. Failed: the effective layer, the
three-way agreement and the deprecated flag. Passed: the store check,
the shadowed-row report and the three controls. |

- **A2 dist proof:** `scripts/ablation-dist-preflight.mjs` found the
guard absent from all 24 built files after the mutated build.
- **Restores:** each restore was proven: the blob equals HEAD, and `git
diff HEAD` is empty. After the restored build, the guard is present in 2
built files and the working tree is clean against HEAD.
- **A first A1 attempt was a no-op.** The replacement text was a
substring of the anchor, so the tool refused it before any test ran and
restored the file. It produced no measurement.

**Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` printed 74 commands for this tree
at `39ed9ac48a`. All 74 were run, each exit code captured before any
pipe. `--ran` reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0
unrun.

- **First pass:** `check:dual-build-cjs-loads` exited 3, `PREREQUISITE
NOT MET`. Eight packages outside this diff had no `dist/` in this fresh
worktree. After building those eight (all turbo cache hits), it exited
0.
- **Roster gates:** the ten roster gates whose roster sits beside a path
of this diff were also run, all exit 0. They are
`check-changeset-fixed`, `check-published-list-mirrors` (plain and
`--self-test`), `check:authz-resolver`, `check:console-injection`,
`check:engine-double-contract`, `check:error-code-casing`,
`check:i18n-stale-fill`, `check:published-readme-exports` and
`check-dts-references --self-test`.
- **Base:** `origin/main` has not moved since the branch point
`2f2fa11d75`.

**Lint, a proven narrowing of `pnpm lint` (the repo-wide run is CI's),
at `39ed9ac48a`:**

1. **Population, from eslint's own config:** of the 5 touched paths, the
config matches the 3 `.ts` files. The `.md` and `.json` files answer
"File ignored because no matching configuration was supplied."
2. **Count, from `--format json`:** 5 results. The 3 linted files have 0
errors and 0 warnings.
3. **Invariance:** `eslint.config.mjs` never enables type-aware linting.
All seven `parserOptions` blocks are `ecmaVersion` and `sourceType`
only, with no `project`. The only other files the config reads are
`scripts/slot-lookup-baseline.json` and
`scripts/query-options-erasure-baseline.json`, and this diff touches
neither. So the diff cannot move the verdict on any untouched file.

**NOT MEASURED locally, declared to CI:**

- Test Core shards, Temporal Conformance, the full Dogfood Regression
Gate and Dogfood Verify CLI.
- Build Core and the workspace type-check lanes.
- The runtime dispatcher's published twin (source reading only).

## Deviations

1. **`Part of objectstack-ai#21002`, not a closing line.** The dispatch named a
closing line. The published-snapshot door half of the card is measured
unresolved and is now a decision for the seat, so this PR does not close
the card. The seat can rewrite the first line if it rules that half out
of the card.
2. **`scripts/engine-double-contract.pinned.json`, one generated row.**
The new unit pin's engine double has a `findOne`, so
`check:engine-double-contract` requires the coverage ledger to learn the
file, through `--write`. The diff is exactly that one row. PR objectstack-ai#20994 has
the same precedent.
3. **No published-snapshot pin.** The dispatch said to stop and report
if that door picks a layer by itself. It does, so the door is measured
and reported here, not pinned.

## Acceptance notes

- **Unchanged, and named:**
  - every other metadata type (the predicate gates on `flow` first);
  - flow names no managed package ships;
- organization-scoped flow rows, which this read never reaches because
`flow` declares no org override;
- the lock, provenance and affordance flags, which already resolved from
the code layer.
- **For an unshipped name with a stored row,** the layered door's code
layer is the stored body (measured on both trees). The code-layer
fallback reaches the registry's bare slot, which holds the hydrated row.
The spec describes that layer as null when no artifact ships the item.
This is reported separately and is not touched here.
- **In the showcase composition,** the published-snapshot door answers
`501 NOT_IMPLEMENTED` for a shipped flow with no stored row. That kernel
has no code/package store for it to fall back to. This bears on what
that door could answer for a shipped name, so it is part of the report.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants