Skip to content

fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) - #20853

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20761-provenance-server-held
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20761-provenance-server-held

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20761
Clause-②: yes (widening)

What this does

Stage 2 of #20761, under the maintainer's ruling B + A recorded in 5904938166. The platform now trusts only a fact it holds itself about which flows are packaged. For flows, "packaged" means exactly "loaded by the loader from a managed package".

  1. The engine reads the loader's set, not the body (ruling point 1). The automation engine no longer classifies a flow by the provenance stamps its own definition carries. Every classification reads one reader over the loader's set: the ADR-0126 §7.3 subflow guards in both directions, the arming gate, the activation door, and the package an activation row is attributed to. The automation plugin attaches that reader. It asks the metadata protocol at question time, with nothing cached at boot, so it follows the registry across install, upgrade and reload exactly as the locked-base verdict does. The body's stamps are kept for display only.
  2. One shared rule on every flow write door (ruling point 2). It lives in @objectstack/metadata-protocol, beside the access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 locked-base verdict. It is asked by the automation create door, the update door, the clone door and the /meta flow write.
    • A name the loader's set holds is refused as a locked base. That is access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679's packagedBaseRefusal, reused, not re-implemented. So a round trip of a shipped flow is refused.
    • Any other name, with a body whose stamps would classify it as code-shipped, is refused loudly: 422 INVALID_METADATA, a code already registered to this package in the ADR-0112 ledger. No new code is minted. Nothing is written or registered.
    • A tenant row's own stamps, echoed on a round trip, agree with the server's fact and are a no-op (see "Round trips" below for the one case this had to measure).
  3. Clone (ruling point 3). The copy still drops the base's whole protection envelope. flow-clone.ts already did that, and the envelope drop is pinned in automation-flow-clone.test.ts. The copy is now also judged by the same rule and saved as a tenant row through the metadata protocol's own save, env-wide. It reads back on the metadata door and survives a cold boot. The engine registers the copy first and the store saves it second, and a save that fails withdraws the registration, so no clone is reported that would not survive.
  4. Scope (ruling point 5). /meta's handling of every other metadata type is unchanged: the rule answers nothing for them. The two server-stated rewrites of stored rows (stored-metadata migration, package duplication) are not authoring doors and keep their old handling.

Declared cross-lane touch

The card is domain:cli, and its entry is the automation doors in packages/runtime. The claim (5909772878) declares two cross-lane surfaces, and this PR stays inside them:

  • domain:engine: metadata-protocol (packages/metadata-protocol/src/**). The shared rule and the loader's-set read, both new public members, and /meta's flow write applying the rule.
  • domain:services: service-automation (packages/services/service-automation/src/**). The engine's classification reads the loader's set, and the plugin wiring hands it over.

Nothing under packages/spec/**, packages/objectql/** or packages/metadata-core/** is touched.

The seam

role file symbol
loader's-set read packages/metadata-protocol/src/protocol.ts ObjectStackProtocolImplementation.packagedArtifactOwner
the one authoring rule packages/metadata-protocol/src/protocol.ts ObjectStackProtocolImplementation.tenantAuthoredWriteRefusal (the lock branch calls packagedBaseRefusal)
/meta applies it packages/metadata-protocol/src/protocol.ts saveMetaItem, before the stamps are stripped
automation doors apply it packages/runtime/src/domains/automation.ts refuseUnauthoredFlowWrite (POST /, PUT /:name, clone)
clone saved as a tenant row packages/runtime/src/domains/automation.ts clone arm → protocol.saveMetaItem
engine reads the set packages/services/service-automation/src/engine.ts setPackagedFlowSource, packagedFlowOwner (every former body-stamp site)
wiring packages/services/service-automation/src/plugin.ts packagedFlowReader

Pins (ruling point 4)

pin where
a disagreeing assertion is refused on create and on update (422 INVALID_METADATA), nothing registered or written runtime automation-tenant-authored-write.test.ts; dogfood
a round trip of a shipped flow is refused as a locked base (403 NOT_OVERRIDABLE) on both doors runtime; metadata-protocol protocol.tenant-authored-write.test.ts; dogfood
a clone of a shipped flow is saved as a tenant row: it reads back on the metadata door and survives a cold boot on the same database file runtime (the save and its rollback); dogfood (read-back and cold boot)
a round trip of a customer flow is accepted unchanged, on both doors runtime; metadata-protocol; dogfood
the §7.3 guards and the toggle door treat a customer flow as customer-authored, whatever its body's stamps say service-automation packaged-flow-source.test.ts; dogfood
no activation row is attributed to a package from an authoring path service-automation (a row is attributed to the package the set names, never the one the stamps name); dogfood (ledger read, no row)
/meta's flow write applies the same rule metadata-protocol; dogfood
control: /meta on another type keeps its old handling metadata-protocol; dogfood

At least one pin runs through the real HTTP composition: packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts boots the showcase through bootStack with a database file, and cold-boots it once.

Ablation, run once and not kept. The five fix sources were reverted to the merge base on the working tree, and the three packages were rebuilt. Each marker was proved absent from dist/ with scripts/ablation-dist-preflight.mjs --absent. The dogfood file then read 8 failed / 5 passed of 13. The five that stay green both ways are the preservation pins: the two locked-base refusals (#20679's), the two /meta round trips, and the other-type control. After the restore, git status --porcelain was empty, the markers were present in dist/ again, and the file read 13 / 13. A first attempt left @objectstack/service-automation's build red: its barrel still exported the new type. That meant @objectstack/runtime's dist/ was never rebuilt, so the first reading was void. It was redone with the barrel reverted too.

Round trips (the triage direction 5903721942)

  • Studio: NOT MEASURED. The Studio source is in objectui, which is not in this container. What Studio receives was measured on the showcase boot: the served document on both read doors.
    • A customer flow created through the automation door is served with no provenance stamps.
    • A customer flow stored through /meta is served with none either.
    • A shipped flow is served with its package's stamps.
    • A customer flow stored bound to a package is served with that binding surfaced as a package stamp and no tenant marker. The canonical classifier alone reads that as code-shipped, so a literal "stamps classify it as code-shipped ⇒ refuse" rule would have broken this legitimate round trip (measured: 422 before the edge was fixed). The rule therefore also asks the server's own fact behind the stamp: the package the stored row of that name is bound to, or the base the write itself names. That round trip is pinned on both doors, including across a cold boot.
  • CLI: os meta register sends a file's contents verbatim to PUT /meta/:type/:name (packages/cli/src/commands/meta/register.ts:59-76), and os meta get prints the served document. So a get → register round trip is the /meta round trip above: a customer flow is accepted, a package-bound customer flow is accepted, and a shipped flow is refused as a locked base. The CLI adds no stamps of its own.

Verification (at f0de8fe69, after merging origin/main 30839063b)

  • pnpm --filter @objectstack/metadata-protocol exec vitest run: 192 files passed, 3 skipped; 2813 tests passed, 19 skipped.
  • pnpm --filter @objectstack/service-automation exec vitest run: 158 / 158 files, 1984 / 1984 tests.
  • pnpm --filter @objectstack/runtime exec vitest run: 298 / 298 files, 4961 passed, 1 skipped.
  • dogfood: flow-provenance-server-held, packaged-flow-write-door-parity, automation-flow-clone-door, automation-toggle-tenant-scope and packaged-activation-ledger-reach passed, 42 / 42.
  • typecheck: metadata-protocol, service-automation (with check:test-typecheck), runtime and dogfood, all exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands: all 69 derived families were run, each with its exit code recorded. --ran reconciles 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.
    • check-plugin-teardown-shape --self-test first exited 3 on the shallow clone. It exited 0 after deepening, as it prescribes.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET: eight unrelated packages had no dist/. It exited 0 after they were built.
  • Lint, as a proven narrowing, not a full pnpm lint:
    1. Population: every changed TypeScript file sits under packages/**, which the packages/**/*.{ts,…} and **/*.{ts,…} blocks of eslint.config.mjs lint.
    2. eslint --no-inline-config --format json over the 23 changed files: 23 files, 0 errors, 0 warnings.
    3. Invariance: the config never enables type-aware linting (eslint.config.mjs:327-328), so this diff cannot move a verdict on any untouched file.

Acceptance notes

  • The automation create and update doors still persist nothing (not built here, as the claim allowed). Measured on the showcase boot with a database file:

    • a flow created through the create door answers 200 and reads 404 after a cold boot;
    • an update through the update door, on a flow stored through /meta, answers 200, and the stored definition wins after a cold boot.

    The clone's save does not cover these doors for free: making them durable changes both doors' contract. Named in the report; no carrier.

  • A /meta flow save naming a package id no package has answers 200 on the showcase's host-config topology. Measured, out of scope. Named in the report.

  • The loader's set is the registry's view, for both the lock and the engine. They read one source, so they cannot disagree. A dev-time artifact reload that adds a code flow is not re-registered into that view until restart (source reading, not measured). That is the same for the access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 lock.

  • The engine used to count a flow stored bound to a package as packaged at boot, because its served definition carries that binding as a package stamp (see "Round trips"). It now reads the loader's set, so that misclassification is gone too.


Generated by Claude Code

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/runtime/src/flow-clone.ts, packages/services/service-automation/src/index.ts) — pages documenting those are invisible to this run
  • 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 — 32 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 05a7547c9f40e0dfab89a34f5e5b60f818af70d2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 05a7547c9f40e0dfab89a34f5e5b60f818af70d2

⚠️ 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 05a7547c9f40e0dfab89a34f5e5b60f818af70d2 → 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: f0de8fe693acb6e58c037f191581c051b1a9f19e
Local-runs: none

This is the record of record for PR #20853 at f0de8fe69, the stage-2 fix of card #20761 under the maintainer's ruling B + A (5904938166).

Inputs:

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

① Derived judgments

(a) Rule 1 — the engine reads the loader's set: RIGHT, with two named residuals.

  • At the head, engine.ts holds no remaining read of the body's stamps on any path that decides package treatment. Every former site now asks isPackagedFlow / packagedFlowOwner: the §7.3 caller guard (packagedSubflowCallers), the removal guard in unregisterFlow, the child-side guard (disabledPackagedSubflows and its ledger walk), the enable guard, the trigger arming gate (declineOntoDisabledSubflows), the toggle door's customer refusal, and the activation row's package attribution, which is now read once with the verdict and never from the definition. The runtime domain and the rest package carry no flow-stamp reader of their own.
  • One source, so the lock and the classification cannot disagree: packagedArtifactOwner answers from lookupArtifactItem → SchemaRegistry.getArtifactItem, which is exactly the lookup packagedBaseRefusal's isArtifactBacked asks. The plugin's packagedFlowReader resolves the protocol service at question time with nothing cached; a protocol registered after the reader was built is still read (pinned).
  • Why the set holds only loader facts: the artifact view admits an entry only when it carries a genuine package id and is not tenant-authored. The loader stamps package provenance at registration; the boot hydration of stored rows forces the tenant marker onto every row (stateTenantAuthorship) and registers it under the bare key with no package argument, so a stored row can never enter that view on its own. The only way a hydrated row acquires code provenance is the graft of a real artifact's envelope of the same name, in which case the name IS in the loader's set.
  • Residual 1, named by the dev and carried: a dev-time artifact reload that adds a code flow is not re-registered into the registry's artifact view until restart (source reading, unmeasured). It is the same gap the access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 lock has, the two read one source, and the direction of error is fail-closed (the new code flow is neither locked nor packaged). Package install and upgrade register through the manifest service, and a Studio republish promotes tenant rows, which are never artifacts, so those cases stay right.
  • Residual 2, not in the dev's report: boot-time precedence (resolveFlowPrecedence in flow-precedence.ts) still classifies its contenders with describeFlowContender, i.e. from the registry bodies' stamps, not from the reader. It is not reachable from an authoring door: its input is the registry's own list at start, populated only by the loader and by the hydration that forces the tenant marker, and the card measured precedence as unaffected by the assertion. The PR body does not claim precedence among the replaced sites, so nothing is overstated; ruling rule 1's parenthetical names it, so the seat should carry it as a follow-up (route the precedence classifier through the same reader). Not blocking.

(b) Rule 2 — one shared function, and the code: RIGHT.

  • tenantAuthoredWriteRefusal is asked by POST /, PUT /:name and the clone arm of the automation domain (through refuseUnauthoredFlowWrite, before the engine is entered), and by saveMetaItem for flows only, before the silent strip. Each door relays the verdict verbatim through errorFromThrown; no door re-derives or re-stamps.
  • A name in the set gets packagedBaseRefusal's own answer (403 NOT_OVERRIDABLE, and the named-base ITEM_LOCKED when /meta's own save names a read-only base), so a round trip of a shipped flow is refused as a locked base on every door. That is access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679's check reused, not re-implemented; the optional packageId added to packagedBaseRefusal only lets /meta's save reach the named-base answer it already gave, and the automation doors pass none, so their answer is unchanged from fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) #20817.
  • INVALID_METADATA / 422 is a ledgered code of @objectstack/metadata-protocol (its row in the ADR-0112 ledger), already the code saveMetaItem emits for a body carrying keys a client may not persist. It is right over the alternatives: 400 would open a second dialect on the same door, 409 is wrong because the name is free and nothing about current state is contested, 403 is wrong because no caller can be authorized into asserting a provenance. No code is minted. Nothing is written or registered on refusal (pinned on both doors and in the dogfood boot).
  • The type is folded at the producer, so a plural spelling cannot address around the rule (pinned).

(c) The agreement edge, judged adversarially: display-only. No reader is fooled.

  • The edge admits stamps that agree with a fact the server already holds: a stored row of that name bound to the stamped package (a store read, any state and scope), or the base the write itself names. The measured cause is real: a stored tenant flow bound to a package is served with its binding surfaced as a package stamp and no tenant marker, so the canonical classifier reads its round trip as code-shipped, and the literal rule broke that round trip (measured 422 before the edge).
  • What the edge can and cannot do. It decides only whether the write is refused. What the write produces is a tenant row: the automation doors register in the engine only, and /meta strips the stamps and stores a row bound at most to the package it already was. At the next boot that row hydrates with the tenant marker forced, under the bare key. "Packaged" for every reader is exclusively the artifact view, which such a row cannot enter. Hence: it cannot lock the name (isArtifactBacked reads the same view), cannot make the §7.3 guards count it as a shipped caller or child, cannot pass the toggle door (refused 409 before any write), and cannot attribute an activation row to the stamped package (the ledger's package is packagedFlowOwner, reachable only past that refusal). The dogfood suite pins exactly this after a cold boot: the package-bound customer flow round-trips through the automation door and is still the customer's, with no ledger row.
  • Together with the dev's second finding (the /meta save door accepts a binding to a package id no installed package has): an author can therefore manufacture a stored row bound to an arbitrary id, and then satisfy the edge for that name with that id. The result is the same tenant row with a misleading display stamp; none of the four readers above changes its answer, because none reads the store's binding or the served body. The one place a bound tenant row WAS read as code-shipped before this PR, the engine's boot-time sync from the protocol's execution view, now reads the set (the dev's fourth Acceptance note). Named-base agreement does not bypass the package door either: the rule runs before refusePackagedBaseOverride, which still judges the named base afterwards.
  • Verdict on the edge: right, and correctly bounded. The store read is paid only on the rare stamped path; an unprovisioned store answers "no" and the refusal stands; any other read failure is re-raised, never turned into a verdict (pinned).

(d) Rule 3 — the clone: RIGHT and pinned.
The copy is judged by the same rule (the protocol is resolved before anything is registered, so a failing slot cannot leave a registered-but-unsaved clone), registered in the engine, then saved through protocol.saveMetaItem env-wide with no organization and no package, with no provenance on the item; a failing save withdraws the registration and relays the store's own failure. Pinned in the runtime suite (save shape, rollback) and in the dogfood boot (reads back on the metadata door, refused at the toggle door as customer-authored, survives a cold boot on the same database file). A composition with no store keeps the engine-only clone, stated in code. One unmeasured edge for the seat, not blocking and not security: the clone door's name-taken check consults the engine only, so a stored-but-unregistered row of the target name (a draft) would be overwritten by the clone's save.

(e) Fail-closed engine: RIGHT. With no reader attached nothing is packaged, the guards protect nothing and the toggle door refuses every flow (pinned). Every real composition has the protocol: ObjectQLPlugin assembles it by default, the verify harness composes ObjectQLPlugin, and the CLI serve roster carries both. The only engine composed without a door is the data-migration helper, which runs the engine unarmed. Observation, declared on both sides: in a composition with no protocol the #20679 lock is fail-open (the #20817 record's declared decision) while this classification is fail-closed; the asymmetry is harmless where no door exists and worth one line in the seat's notes.

(f) The reversal of #20679's pin: CONSISTENT with ruling point 2. The pin asserted that a body claiming a package on the customer's own flow is accepted 200; the ruling (rendered before that pin's record) says such a body is refused loudly with an existing code, and its point 4 replaced triage's earlier round-trip pin. The assertion now reads 422 INVALID_METADATA, engine never entered, definition unchanged. Rightly listed as a deviation and rightly made.

(g) Scope (rule 5): RIGHT. The rule answers null for every non-flow type, so /meta's handling of other types is unchanged (pinned as a control on both the unit and dogfood sides). The two exemptions are the two server-stated rewrites and nothing else: migrateStoredMetadata passes its stated source and duplicatePackage its stated write face; neither the REST door, the runtime dispatcher door nor the automation clone passes a source, and no door forwards one from a client, so the exemption cannot be reached from the wire (pinned: an authoring save asks the rule once, the two rewrites ask it zero times). Exempting the rewrites is right: a pre-#16702 row may carry stale stamps and failing its rewrite would be a regression of a server act.

(h) Prose, PR body and titles: ACCURATE and within the disclosure discipline. The changeset names exactly the delivered members (setPackagedFlowSource, packagedFlowOwner, the exported PackagedFlowSource type, packagedArtifactOwner, tenantAuthoredWriteRefusal, the optional named base on packagedBaseRefusal), the two statuses, the clone's save and rollback, the other-type scope, and carries the author's one-line fix. The PR body's "Round trips" section states Studio as NOT MEASURED with the server-side echo measured instead, and the ablation's first void run is disclosed. Neither the changeset, the PR body, the report nor any test title spells a request body, header or field key; stamps and markers are named abstractly. The refusal text itself names the keys to remove, which is runtime prose to the author, not a public surface. No tracker number in runtime strings.

Surface inventory:

  1. @objectstack/metadata-protocol: packagedArtifactOwner, tenantAuthoredWriteRefusal, and the optional named base on packagedBaseRefusal: widenings, right.
  2. @objectstack/service-automation: setPackagedFlowSource, packagedFlowOwner, the PackagedFlowSource type: widenings, right. packagedFlowReader is exported from the plugin module but not from the barrel. The test stand-in reads stamps by design and is test-only, not barrel-reachable.
  3. Behaviour: the four flow write doors refuse a client-asserted provenance (422) and a shipped name (403); the clone is persisted; the engine's package treatment is the loader's fact. No route, query set or schema changes.

② Semver level

  • minor for @objectstack/metadata-protocol and @objectstack/service-automation (added public members): right.
  • patch for @objectstack/runtime: right. No public member is added; the doors move to the declared contract.
  • Clause-②: yes (widening): right. The new 422 on the authoring doors is not a narrowing that needs (narrowing), a BREAKING banner or an ADR-0087 disposition. The accept-set that shrank was never declared: the spec's provenance decorations are server-derived read decorations (loader-introduced items carry package provenance), ADR-0126 §2 locks the packaged base and forbids silent override, ADR-0131 D6 seals managed definitions, and /meta already treated the stamps as not the client's (it stripped them). Accepting the assertion on the automation doors was the defect this security card measured. That is the negative boundary of clause ② exactly as the fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) #20817 record judged the same door family's 403, and here the maintainer's ruling itself orders the loud refusal over the silent strip. The changeset still carries the migration sentence, which is right practice for a refusal that a get-edit-register workflow will meet. Check Changeset and the repo gates are green.

③ Boundary flags

  • Deviations, all five answered: the dedicated worktree on the claim branch (fast-forward, no force-push) is the rule, not a deviation; the access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 pin reversal is ordered by ruling point 2 (see ① f); the ten suites attaching a test-only stand-in keep their guard-logic pins honest while the set-vs-stamps contract is pinned separately; the fold-population count 14 → 16 reflects the two new folding verbs; the void first ablation was redone with the barrel reverted and disclosed.
  • Open questions, all five self-decided rightly: Q1 INVALID_METADATA / 422 (see ① b); Q2 agreement with the server's fact (see ① c, display-only); Q3 fail closed with no reader (see ① e); Q4 the two server-stated rewrites exempt, and only those (see ① g); Q5 runtime patch (see ②).
  • Out-of-scope findings:
    • Class a, the automation create and update doors persist nothing (200, then 404 or the stored definition after a cold boot): measured on a real boot with a database file at the head, so the evidence supports class and reach (any author of those doors). It confirms the seat's own evidence 5905846738 items 1–2. The claim allowed naming it; the seat files it, and the clone's new save is the shape to reuse.
    • Class a, the /meta flow save accepts a binding to a package id no installed package has (200 on the host-config topology): measured at the head, so the evidence supports class. Its reach must be stated as it now stands: after this PR the orphan binding is display-only (it cannot lock, classify or attribute, see ① c); at the merge-base it had engine reach through the boot-time sync. Measured on one topology; the environment-kernel path is not stated. The seat files it with that reach.
    • The dev-time artifact reload observation: carried as residual 1 in ① a, shared with access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679, fail-closed, unmeasured.
  • Residuals from this review, none blocking: boot-time precedence still classifies from registry bodies (① a residual 2, follow-up for the seat); the clone-onto-stored-draft overwrite edge (① d, unmeasured); the fail-open/fail-closed asymmetry in a protocol-less composition (① e, observation).
  • origin/main has advanced past the merge-base; the head's mergeable state is clean and the diff reviewed is the net diff against the merge-base.

Implemented-by: claude/issue-20761-provenance-server-held
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 13:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 76bd58f Sep 30, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20761-provenance-server-held branch September 30, 2026 13:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ers by the loader's set (objectstack-ai#20880)

Fixes objectstack-ai#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 objectstack-ai#20853, instead of from the flow bodies' package-provenance stamps.
This finishes rule 1 of the ruling recorded on objectstack-ai#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
objectstack-ai#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 objectstack-ai#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 objectstack-ai#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`):

- M1 classifies the stamped body as `runtime`.
- M3 arms the same flows with the same receipts as before, for the
shapes measured both times:
  - the grafted overlay's name arms the loader entry, by arrival order;
  - `alpha` wins over `beta`.
- A plain overlay beside a loader entry still wins. That is the objectstack-ai#11997
suite's case, and its expectations are unchanged.

## 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` (objectstack-ai#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 objectstack-ai#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 objectstack-ai#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](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
…as a tenant row, so what they answer 200 for survives a restart (objectstack-ai#20862) (objectstack-ai#20907)

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

## What this does

`POST /api/v1/automation` and `PUT /api/v1/automation/NAME` registered a
flow in the automation engine and wrote no metadata row. The next boot
binds flows from the stored metadata, so a created flow was gone after a
restart, and an update to a flow stored through `/meta` lost to the
stored definition. Both doors now save the flow as a tenant row through
the metadata protocol's own `saveMetaItem`, env-wide, which is the path
PR objectstack-ai#20853 gave the clone door. This follows the triage direction on the
card (`5912757695`) and the maintainer's rule 2 recorded on objectstack-ai#20761
(`5904938166`): every flow written through an authoring door goes
through one shared function.

- **One path for three doors.** `registerAndSaveFlow` in
`packages/runtime/src/domains/automation.ts` registers the definition in
the engine, then saves the same definition through `saveMetaItem({ type:
'flow', name, item })`. It passes no organization, no package and no
mode, so the row is live (`active`), like the engine registration. The
create door, the update door and the clone door all call it. There is no
second persistence path, and `saveMetaItem` is called, not changed.
- **Engine first, store second.** The engine's registration is still the
first check. Its refusal is answered as before (`400
VALIDATION_FAILED`), and nothing is saved.
- **A failed save leaves no registration of its own.** The store's
refusal is relayed with its own code and status. A new name is withdrawn
from the engine. A name the engine already held gets back the definition
it held, so a refused update does not take the flow down.
- **The refusals in front stay in front.** The objectstack-ai#20679 locked-base
refusal on a packaged flow's name (`403 NOT_OVERRIDABLE`) and objectstack-ai#20853's
`tenantAuthoredWriteRefusal` (`422 INVALID_METADATA`) still answer
before the engine or the store is touched.
- **No store, no save.** A composition with no metadata protocol keeps
the engine-only registration it always had, as the clone door already
did. The runtime pin states this case.
- **Draft or live:** the save uses `saveMetaItem`'s default mode, which
is `publish`, so the row is `active`. That matches the clone door. A
draft row would leave the engine running a flow the next boot does not
bind.

### The removal door keeps pace (beyond the card's two arms, declared)

Once the create door saves, `DELETE /automation/NAME`, which only
unregistered the flow in the engine, would leave the row behind, and the
next boot would bind the flow again. This was measured with the create
and update half alone on `1921e7e3e2`: a flow created then deleted
through the door still read `200` on `GET /meta/flow/NAME`, and `200` on
`GET /automation/NAME` after a cold boot. So `unregisterAndDeleteFlow`
removes the flow from the engine, then deletes the row through the
protocol's own `deleteMetaItem`, env-wide.

- The engine's removal refusal (`DELETE_RESTRICTED` / `409`) is still
raised before the store is touched.
- A name with no row is removed as before.
- A delete the store refuses puts the definition back in the engine and
relays the refusal.

## Repro, before this change

Measured with the booted pin
`packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts`
on base `2d5fe76f43` plus the pin commit `d09cee8a9c`, through
`bootStack` on the showcase with a database file:

- `POST /automation` answered `200`. `GET /meta/flow/NAME` answered `404
RESOURCE_NOT_FOUND`. After a cold boot on the same file, `GET
/automation/NAME` answered `404`.
- `PUT /automation/NAME` on a flow stored through `/meta` answered
`200`. `/meta` still served `Stored through /meta`. After the cold boot,
`GET /automation/NAME` served `Stored through /meta`, not the update.

## Declared narrowing

`Clause-②: no (narrowing)`. The changeset ships `minor` with a BREAKING
banner and an ADR-0087 disposition (`not-required
(no-migration-prescription)`). Nothing widens. The doors now refuse what
the metadata store refuses. Two bodies measured on the showcase answered
`200` before and are refused now:

- **A flow name with a leading underscore.** `FlowSchema.name` admits it
and the metadata item-name grammar does not. The door now answers `400
INVALID_REQUEST`.
- **A definition the runtime publish gate refuses.** The measured case
is a default edge that carries a condition
(`flow-default-edge-with-condition`). The door now answers `422
INVALID_METADATA`.

Neither flow could ever be stored, so before this change it ran only
until the next restart. The claim's reading was `no`. The measured diff
moves the doors' accept set for these bodies, so the declaration names
the arm.

## Pins

-
`packages/runtime/src/domains/automation-authoring-doors-durable.test.ts`
(new, 11 cases). It uses a real protocol over a real `SchemaRegistry`,
with the store's save and delete spied. It pins:
  - the save arguments, and that the engine is called first;
  - a failed create is withdrawn;
  - a failed update puts back the definition the engine held;
  - an engine refusal saves nothing;
- the locked-base and provenance refusals reach neither the engine nor
the store;
- the removal door's delete, its undo, and the §7.3 refusal coming
first;
  - a composition with no protocol.
-
`packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts`
(new, 7 cases, cold boot on one database file):
  - a created flow survives;
  - an update to a `/meta`-stored flow survives;
- a create the store refuses answers `400 INVALID_REQUEST` and `GET`
answers `404`;
- an update the store refuses answers `422 INVALID_METADATA` and the
engine keeps the previous definition;
  - a flow created then deleted stays deleted;
- control: a packaged name is still refused `403 NOT_OVERRIDABLE`, on
`PUT` and on a create onto it.
- `packages/runtime/src/domains/automation-packaged-base-lock.test.ts`:
the harness gains `standInStore()`. Four success-path cases now reach
the real save, and this harness's engine has no store (measured: 4 cases
at `500`, `this.engine.find is not a function`). They stand the store
in, the way the file's own clone case already did. No assertion changed.

## Verification, at head `43cf17d79b`

- `pnpm --filter @objectstack/runtime test` (local project): 296 files,
4245 passed, 1 skipped, `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/runtime typecheck` (tsc plus the test
layer) and `pnpm --filter @objectstack/dogfood typecheck`: exit 0.
- Dogfood: the new pin plus `flow-provenance-server-held`,
`packaged-flow-write-door-parity`, `automation-flow-clone-door` and
`automation-toggle-tenant-scope`: 5 files, 38 tests, exit 0. The runtime
`dist/` was rebuilt at this head first.
- `node scripts/pm/dispatch-gates.mjs --commands`: all 65 derived
commands were run, each exit recorded, and `--ran` answered `65 run, 0
NOT-MEASURED, 0 UNRUN`.
- `pnpm lint` (`eslint . --no-inline-config`, whole tree): exit 0.
- Also run: `pnpm check:durability-log-level` and `pnpm
check:adr-anchors`, exit 0 (on `a783880517`).
- **Reverse verification.** These were one-shot ablations through
`scripts/ablation-replace.mjs` in wrap mode, against the committed
implementation. The runtime pin imports source, so there is no `dist`
leg. Each restore was proven: blob equal to HEAD `2df430b334b6`, and
`git diff HEAD` empty.
  - Save call removed: 4 of 11 red.
  - Delete call removed: 2 of 11 red.
  - Held-definition restore disabled: 1 of 11 red.
- The booted pin's before-leg is the repro above: 5 of 7 red on the
unfixed tree.

## Acceptance notes (observations, not filed)

- **A tenant-package-bound row.** The update door saves env-wide with no
package, as the triage direction says. On a flow stored through `/meta`
with `?package=`, the update therefore lands as a package-less row
beside the bound one. A bare `PUT /meta/flow/NAME` already does the same
today. Which row a later boot binds was NOT MEASURED. Taker: none.
- **Item-name grammar and `FlowSchema.name` disagree** on a leading
underscore. The refusal is loud and prescriptive at every runtime door,
and a code-shipped flow is loaded, not saved, so this is not a silent
trap. Taker: none.
- **Drafts.** `DELETE` removes the live row only, as `/meta`'s delete
without a state does. A pending draft row stays. Taker: none.
- **Version history.** Putting back a held definition re-registers it,
which adds one entry to the engine's in-memory version history. No
runtime door serves that history. Taker: none.
- **Stale tree.** `origin/main` moved after the merge `a783880517`. The
only gate-family input that changed is
`scripts/doc-authoring-prose-id.baseline.json`, which this diff does not
touch. CI measures the merge ref.

---
_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
…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
…flow for a shipped flow name with a stored row, as the layered read does (objectstack-ai#21002) (objectstack-ai#21116)

Fixes objectstack-ai#21002
Clause-②: yes (widening)

The published-snapshot read of a flow name a managed package ships now
answers the package's flow when a stored row of that name is at rest.
This holds on the REST route and on its runtime-dispatcher twin. This is
the second half of objectstack-ai#21002, as triage ruled in `5924438659`: option A,
scoped by the decision, not by type. The first half, the layered read,
landed in PR objectstack-ai#21043.

⚠️ This body follows the objectstack-ai#20761 family's disclosure discipline. It talks
about doors, roles, codes and statuses only. It has no request body,
header or field spelling, and no seeding steps.

## What changed

**`packages/rest/src/rest-server.ts`**, the `GET
/meta/:type/:name/published` handler:
- It still reads the layered answer first. When that answer has a stored
layer, the door now asks the protocol's `isShippedFlowName` about the
answer's own type and name.
- When the predicate holds, the layered read has put the loader's body
over the stored row. The door then serves the effective layer, which is
the loader's body.
- In every other case it serves the stored layer, exactly as before. A
protocol that brings no such predicate also keeps today's answer.

**`packages/runtime/src/domains/meta.ts`**, the dispatcher twin of that
route:
- The same change, in the same place.
- `MetaDomainProtocol` gains the predicate as an optional member. It is
`Pick`ed from `ObjectStackProtocolImplementation`, not restated, so a
rename at the producer is a compile error here. This is the same move
`domains/automation.ts` makes for `packagedBaseRefusal`.

**`packages/metadata-protocol/src/protocol.ts`**, the declared
cross-lane surface the claim allows:
- `isShippedFlowName` changes from `private` to public. Its body is
unchanged.
- A docblock paragraph names the doors that ask it.
- `getMetaItemLayered`'s effective-layer decision (PR objectstack-ai#21043) is not
touched.

**`.changeset/21002-published-door-shipped-flow.md`**:
`@objectstack/metadata-protocol` `minor`, `@objectstack/rest` `patch`,
`@objectstack/runtime` `patch`.

## How the doors learn the decision

There is one decision point, the predicate PR objectstack-ai#21043 already calls.
- No per-type list, no new response key, no fourth precedence path, and
no second copy of the rule.
- Each door asks the protocol's own predicate about the type and name
the layered answer reports. The layered answer's type is the canonical
singular, so the predicate gets exactly the arguments
`getMetaItemLayered` used.
- `object` is never named. Its effective layer differs from its stored
layer by folding and governance, not by this decision, so it is served
byte-identically.

The predicate was private to the protocol class. A door in another
package could not reach it except by copying the rule (the flow-only
scoping plus `packagedArtifactOwner`), which the ruling forbids. So
making it public is the minimal reachability change.

## Clause ② reads `yes (widening)`, not the claim's `no`

The claim's own reading said the dev re-reads the line against the real
diff. The real diff adds one public member to a class
`@objectstack/metadata-protocol` exports, so its published declaration
grows.
- The rule `check-changeset-no-major.mjs` quotes says a purely additive
widening of a published package's public surface takes at least `minor`.
- The two in-family precedents read it the same way. PR objectstack-ai#20817 made
`packagedBaseRefusal` public, and PR objectstack-ai#20853 made
`tenantAuthoredWriteRefusal` public. Both were declared `Clause-②: yes
(widening)` with `@objectstack/metadata-protocol` at `minor`.
- No wire shape moves. No request key, response key or accept set
changes. `rest` and `runtime` stay `patch`: `MetaDomainProtocol` is not
exported from the runtime package entry.

If the seat rules this `no`, the revert is two lines: this body's second
line, and the changeset's `minor` back to `patch` with its own Clause
line.

## Reproduction

Showcase composition on a database file, cold boot, signed-in admin. The
stored rows were written on a first boot and read on the second. The
base is `63d1a7c378`.

| case | door | before | after |
|:---|:---|:---|:---|
| shipped flow name, stored row | REST | `200`, the stored body | `200`,
the loader's body |
| shipped flow name, stored row | dispatcher twin | `200`, the stored
body | `200`, the loader's body |
| flow name no package ships, stored row | REST and twin | `200`, the
stored body | unchanged, same bytes |
| `object`, published stored layer | REST and twin | `200`, the stored
layer | unchanged, same bytes |

"Same bytes" means the SHA-256 of the served document is equal before
and after, on both doors. The dispatcher figures come from a throwaway
probe that drove `HttpDispatcher` in-process over the booted kernel. The
probe was deleted.

## Pins

- **REST unit**, `packages/rest/src/meta-published-overlay.test.ts`: 5
new cases. They use the real protocol and the file's own engine double,
with a registry that ships one flow from a package.
- A shipped name with a stored row answers the loader's body, equal to
the layered effective layer.
  - The plural type spelling reaches the same decision.
- Controls: a flow name no package ships keeps its stored row. An
`object`'s published stored row is served as stored, and its effective
layer is shown to differ. A protocol without the predicate keeps the
stored row.
- **Runtime unit**,
`packages/runtime/src/domains/meta-published-runtime-publish.test.ts`:
the same 5 cases, through the real `HttpDispatcher`.
- **Dogfood**, the new file
`packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts`:
5 cases, showcase, cold boot.
- A store check, plus the layered read putting the loader's body over
the stored row.
  - The REST door answers the loader's body.
  - The REST door's body equals the layered effective layer.
- Controls: a flow name no package ships keeps its stored body. An
`object`'s published stored layer is served unchanged, and its effective
layer is shown to differ.
- No existing `flow-shipped-name-*.dogfood.test.ts` file is edited.
Neither unit file gains an engine double, so
`scripts/engine-double-contract.pinned.json` is untouched.

**Why the dispatcher twin is not in the dogfood file.** The verify
harness mounts no dispatcher `/meta` catch-all. That route is reached
only on hosts that mount `@objectstack/hono`'s catch-all, so in this
composition the twin is not served at all. Driving `HttpDispatcher`
in-process from the dogfood package means importing runtime source.
`check:test-source-alias` then refuses four new dist-resolved imports
for the dogfood package (`metadata-protocol`, `observability`, `rest`,
`service-datasource`). Its remedy is to alias them to source in the
dogfood vitest config, which is outside this claim's file surface and
would change every isolated dogfood test's resolution. So the twin is
pinned at the unit level, with the real protocol and the real
dispatcher.

## Ablation

The fix was committed first. Both mutations went through
`scripts/ablation-replace.mjs`, replacing the predicate clause with a
constant false. Each leg is shown below.

- **REST door** (`rest-server.ts`), at head `292cc60f45`:
- The anchor went from 1 to 0 hits, and the blob from `a97cfde7c227` to
`418bc95a799c`.
- Unit (source-resolved): 2 failed (shipped name, plural spelling), 12
passed.
- Rebuild of `@objectstack/rest`. Then `ablation-dist-preflight
--absent` found the predicate call absent from all 6 built files.
- Dogfood (dist-resolved): 2 failed (the REST door, and its equality
with the effective layer), 3 passed (the store check and both controls).
- Restore: blob equals HEAD `a97cfde7c227` and `git diff HEAD` is empty.
After the rebuild, the preflight found the call present in 2 built
files, and the tree was clean.
- **Dispatcher twin** (`meta.ts`), at head `292cc60f45`:
- The anchor went from 1 to 0 hits, and the blob from `0c4ddceecbb8` to
`1e10bb639fc9`.
- Unit (source-resolved): 2 failed (shipped name, plural spelling), 8
passed.
  - Restore: blob equals HEAD and `git diff HEAD` is empty.

## Verification

All at head `292cc60f45` unless a line names another.

- **Unit pins:** `meta-published-overlay.test.ts` 14 passed (9 existing,
5 new). `meta-published-runtime-publish.test.ts` 10 passed (5 existing,
5 new). Both at `92f242cee4`, and neither file has changed since.
- **Whole packages:**
- `@objectstack/rest`: 259 files, 5035 passed, 143 skipped, at
`b973faeca2`.
- `@objectstack/runtime` (`--project local`): 297 files, 4254 passed, 5
skipped, at `b973faeca2`.
- The only later change in either package is the reworded docblock and
one dropped fixture key in its unit file. Both files were re-run green
after it.
- `@objectstack/metadata-protocol`: 196 files passed and 3 skipped; 2930
tests passed and 19 skipped, at `92f242cee4`.
- **Dogfood:** the new file, 5 passed.
- **Typecheck:** `metadata-protocol`, `rest`, `runtime` (including
`check:test-typecheck`) and `dogfood` all exit 0. `tsc --listFiles`
counts each new or edited test file once in its own program.
- **Gates:** `dispatch-gates --repo objectstack-ai/objectstack
--commands` derived 68 families. All 68 were run, each exit code
recorded before any pipe, and every one is 0. `--ran` reports 68
derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
  - The first pass, at `92f242cee4`, had two non-zero exits.
- `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: 8
packages outside the diff had no dist. They were built (41 of 41 turbo
cache hits).
- `check:test-source-alias` exited 1 on the dogfood dispatcher leg,
which was then removed. The reason is under Pins.
- **Lint**, a proven narrowing:
- Population, from eslint's own config: 6 of the 7 touched paths are
linted. The changeset is ignored, with no matching configuration.
- Count, from `--format json` with the `pnpm lint` flags: 6 results, 0
errors, 0 warnings.
- Invariance: there is no type-aware linting. Every `parserOptions`
block is `ecmaVersion` and `sourceType` only. The config reads only two
baseline JSON files, and this diff touches neither. So no untouched
file's verdict can move.
- **Not measured locally, declared to CI:** the Test Core shards, the
full Dogfood Regression Gate, Temporal Conformance, Build Core, the
type-check lanes, and the runtime `repo` test project.

## Acceptance notes

- **Unchanged background fact**, per the review `5924388874`: in the
showcase composition, a shipped flow with no stored row answers `501`
`NOT_IMPLEMENTED` on the published door. That kernel has no code/package
store. This PR does not change that path.
- **Not merged with `origin/main`:** 9 commits landed since the base.
None of them touches the 7 files here. PR CI tests the merge ref.
- **Read, not edited:** `getMetaItemLayered` and the predicate's body.

---
_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