Skip to content

fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) - #20817

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-20679-automation-door-package-lock
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/issue-20679-automation-door-package-lock

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20679
Clause-②: yes (widening)

The /automation definition doors now refuse a packaged flow, with the same locked-base verdict the metadata door gives. This is the public checklist item access-security.packaged-flow-write-door-parity: clauses 2 and 3 flip to PASS, and clauses 1 and 4 still pass, measured on a booted showcase. The reproduction stays withheld as filed. This body stays at the door level the public item already describes.

What changed

packages/runtime/src/domains/automation.ts

  • PUT /:name and DELETE /:name call refusePackagedFlowBaseChange before the engine is called, and relay its refusal.
    • Order at each door: manage_metadata authoring gate, then the body envelope check (PUT and POST only), then the lock, then the engine.
    • A refused write reads and registers nothing. A refused removal unregisters nothing.
  • Bounded in-place fix, adopted onto the claim's surface by the seat: POST / onto a name the engine already holds overwrites that flow. It now takes the same lock, right after the name check.
    • Evidence at 3690c44b, before the fix, from a throwaway probe (real protocol over a real SchemaRegistry, deleted afterwards): a create onto a packaged flow's name answered 200, registerFlow was entered once, and the packaged flow's label was overwritten.
    • Pinned now: 403 NOT_OVERRIDABLE, registerFlow never entered, and a create under a new name still succeeds.
  • The helper resolves the protocol slot with resolveServiceOrLoud, unscoped, exactly as the /meta domain resolves it. So both doors ask the same protocol instance.
    • If the slot is wired but fails to resolve, the error is re-raised: the write fails and does not proceed as unlocked.
    • A composition with no metadata protocol keeps today's behaviour. It has no /meta door to be at parity with.

packages/metadata-protocol/src/protocol.ts: the widening, a cross-lane surface the seat adopted.

  • New public method ObjectStackProtocolImplementation.packagedBaseRefusal({ type, name, operation }). It returns the refusal the /meta door gives for writing ('save') or removing ('delete') an existing item that a code package ships, or null when that door would not refuse on this ground.
    • The /meta verdict (isArtifactBacked + isOverlayAllowed, and its emitters) was private to this class, so a second door could not ask it any other way.
  • The verdict is lifted, not copied. Two private helpers, refusePackagedBaseOverride and refusePackagedBaseRemoval, carry saveMetaItem's and deleteMetaItem's inline package-door code verbatim (proof below).
    • Both methods call the helpers where the inline code stood.
    • The helpers still throw, as that code did.
    • packagedBaseRefusal is the one place a throw becomes a value. It re-raises anything that is not a 403 NOT_OVERRIDABLE / ITEM_LOCKED.
  • Why runtime relays the refusal instead of stamping its own code. NOT_OVERRIDABLE and ITEM_LOCKED are ledgered under @objectstack/metadata-protocol (ADR-0112). @objectstack/runtime's owner key lists neither.
    • A runtime stamp would need a ledger row or a waiver in packages/spec.
    • It would also be a second emitter for one condition.
    • Relayed through errorFromThrown, the code, status and sentence are the producer's.
    • check:error-code-provenance: 330 stamp sites, 313 listed, 17 waived, OK.

What the lock keys on. The flow's NAME, looked up in the registry's artifact-only lookup (SchemaRegistry.getArtifactItem, packages/objectql/src/registry.ts:3919). That lookup scans the PACKAGE_ID:NAME entries the artifact loader registers.

  • The door hands the verdict { type, name, operation } and nothing else.
  • Neither the request body nor the engine's registered flow is consulted.
  • Pinned both ways: a packaged name is refused whatever provenance stamps the body carries, and a customer flow is not locked by a body that claims a package.

Two refusals on DELETE. The lock (403 NOT_OVERRIDABLE) answers before the engine's ADR-0126 §7.3 refusal (DELETE_RESTRICTED / 409, a packaged subflow that packaged callers still reach).

  • Every packaged flow is locked first, so at this door the §7.3 refusal is reached only where the lock admits the removal: with OS_METADATA_WRITABLE=flow.
  • The engine's guard is unchanged.
  • ⛔ No lock was added to registerFlow / unregisterFlow: the boot pull registers packaged flows through them.

What stays open.

Lift proof: each lifted helper body, before and after, whitespace-insensitive

git show f284ab26:packages/metadata-protocol/src/protocol.ts | sed -n '16014,16093p' | tee old-save.txt | wc -l     # 80: saveMetaItem's inline package door
git show f5ea0060:packages/metadata-protocol/src/protocol.ts | sed -n '14300,14379p' | tee new-save.txt | wc -l     # 80: refusePackagedBaseOverride body
diff -w old-save.txt new-save.txt | wc -l        # 0

git show f284ab26:packages/metadata-protocol/src/protocol.ts | sed -n '21920,21931p' | tee old-delete.txt | wc -l   # 12: deleteMetaItem's inline refusal
git show f5ea0060:packages/metadata-protocol/src/protocol.ts | sed -n '14401,14412p' | tee new-delete.txt | wc -l   # 12: refusePackagedBaseRemoval body
diff -w old-delete.txt new-delete.txt | wc -l    # 0
helper lines before lines after diff -w changed lines
refusePackagedBaseOverride 80 80 0
refusePackagedBaseRemoval 12 12 0

The only added lines compute the locals the inline code read from its enclosing method:

  • overlayAllowed in the override helper;
  • overlayAllowed and artifactBacked in the removal helper.

Each is spelled exactly as in the calling method. deleteMetaItem keeps its own copies for its NOT_CREATABLE check.

Pins

  • packages/runtime/src/domains/automation-packaged-base-lock.test.ts (15 cases). Real ObjectStackProtocolImplementation over a real SchemaRegistry, with the packaged flow registered the way the loader registers it; the automation service is a spy.
    • PUT and DELETE refused on a host-config kernel and on an environment kernel.
    • The door's answer equals saveMetaItem's / deleteMetaItem's thrown refusal: code, status and sentence.
    • The POST create-overwrite.
    • Body stamps decide nothing.
    • The envelope check keeps its place.
    • Controls: customer flow, runtime-row and tenant-bound registry items, clone, toggle, the hatch, no protocol, a failing protocol (not fail-open).
  • packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts (9 cases):
  • packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts: the derived fold population gains packagedBaseRefusal ("all fourteen").
  • packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts (5 cases), the real showcase composition over HTTP:
    • Clause 1 control: PUT /meta/flow/:name answers 403, code NOT_OVERRIDABLE, in the REST door's envelope.
    • Clauses 2 and 3: PUT / DELETE /automation/:name answer 403 NOT_OVERRIDABLE, and the definition reads back byte-identical.
    • Clause 4: no residue. The enabled/bound row is unchanged.
    • A customer flow is created, updated and removed through the same door.

Tests (measured)

At 3690c44b (merge over #20726):

  • @objectstack/runtime local project: 292 files, 4215 passed, 1 skipped.
  • @objectstack/runtime repo project: 727 passed.
  • @objectstack/metadata-protocol: 191 files passed (3 skipped); 2801 passed, 19 skipped.
  • @objectstack/metadata-protocol typecheck: exit 0.
  • @objectstack/objectql, the 20 files pinning NOT_OVERRIDABLE / NOT_CREATABLE / ITEM_LOCKED on saveMetaItem / deleteMetaItem: 362 passed.
  • @objectstack/rest, 3 files: 41 passed.

At f5ea0060 (head):

  • Every automation-*.test.ts in @objectstack/runtime: 25 files, 478 passed.
  • @objectstack/runtime typecheck: exit 0, check:test-typecheck OK.
  • The two protocol pin files: 27 passed.
  • Dogfood pin, after a runtime rebuild: 5 passed.

Reverse verification

Each leg was committed first, then mutated through scripts/ablation-replace.mjs (anchor hit 1, blob changed). Dist legs were proven by ablation-dist-preflight (marker in 2 built files). Every restore was proven (blob == HEAD, git diff HEAD empty, tree clean, marker absent from dist). Predicted and measured agree on every leg.

leg head suite predicted measured
door helper disabled (runtime src) 3690c44b runtime pin 7 red / 7 green 7 red / 7 green
same, runtime rebuilt 3690c44b dogfood pin 3 red / 2 green 3 red / 2 green
packagedBaseRefusal returns null (rebuilt) 3690c44b protocol pin 6 red / 3 green 6 red / 3 green
same 3690c44b runtime pin 6 red / 8 green 6 red / 8 green
re-raise discriminator widened 3690c44b protocol pin 1 red / 8 green 1 red / 8 green
POST call removed f5ea0060 runtime pin 1 red / 14 green 1 red / 14 green

After every restore, all pins were green again.

Gates

  • dispatch-gates --commands at f5ea0060: 67 derived. --ran reconciliation: 67 run, 0 NOT-MEASURED, 0 unrun, every exit 0.
  • Also run: check:error-code-casing and @objectstack/spec check:error-code-provenance, both exit 0.
  • pnpm lint, narrowed and proven:
    1. The population comes from eslint.config.mjs (the **/*.{ts,…} and packages/**/*.{ts,…} blocks). All 6 changed .ts files are in it.
    2. --format json counted 6 files linted, 0 errors, 0 warnings, at f5ea0060.
    3. The config never enables type-aware linting (eslint.config.mjs states it: no parserOptions.project, no typed rules), and its four plugins are local AST rules. So this diff cannot move any untouched file's verdict.

Acceptance notes (observed, not filed)

  • The ADR-0126 §2 refusal wording. §2 says the refusal names the sanctioned path. For a packaged flow, the shared NOT_OVERRIDABLE sentence names "edit the source artifact and redeploy" and the OS_METADATA_WRITABLE hatch. It does not name clone (§7.1). This holds on both doors, because the sentence is one emitter. It is reported to the seat, not changed here.
  • Two envelopes for one refusal. On this composition /meta answers through the REST server's refusal envelope, { error, code } with a flat string error. /automation answers through the dispatcher's, { success, error: { code, message } }. Pre-existing. The dogfood pin reads each where it lives.
  • @objectstack/rest inlines the protocol. It declares @objectstack/metadata-protocol as a devDependency, so its built dist/ carries its own copy of the protocol class. Pre-existing. It is rebuilt with the same source.

Generated by Claude Code

… the packaged-base lock

PUT /automation/:name and DELETE /automation/:name now ask the metadata
protocol's own locked-base verdict (packagedBaseRefusal, lifted out of
saveMetaItem / deleteMetaItem unchanged) before the engine is called, and
relay its refusal verbatim.

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

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

The two helpers now carry the original inline lines unchanged (they throw,
as that code did); packagedBaseRefusal is the one place a throw becomes a
value, and it re-raises anything that is not the refusal. The changeset
states the widening.

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

POST /automation onto a name the engine already holds is an overwrite; it
now asks the same locked-base verdict as PUT /:name before anything is read
or registered. Bounded in-place fix, adopted onto the claim's surface.

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

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/system-context.mdx (via handleAutomationRequest (symbol, a top-level function))

⛔ 3 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/v17/17-3.mdx (via automation.toggle (sdk, the route ledger binds it to POST /automation/:name/toggle, selected by route anchor /:name/toggle), /:name/toggle (route, a path literal in a comment on a changed line))

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 anchor(s) matched too much of the corpus to be a work list: /automation/:name (route, 36 pages)
  • 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 — 31 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 79114850f0f559dcb4fb432f5bcfc12947a03de6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 79114850f0f559dcb4fb432f5bcfc12947a03de6

⚠️ 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 79114850f0f559dcb4fb432f5bcfc12947a03de6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The refusal's code and status match the metadata door everywhere; its
sentence matches wherever the metadata protocol's own package door answers.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e4006d68594d433159b1a0e8addd9585e5929f59
Local-runs: none

This is the record of record for PR #20817 at e4006d68. It is a delta re-review over the record rendered on f5ea0060, and its ①–③ judgments are carried forward because the code is byte-identical.

Inputs:

Disclosure is kept at the public item's level: doors, roles, codes and statuses.

Seat verification on adoption:

  • diff -w of saveMetaItem's inline package door at the merge-base (80 lines) against the lifted refusePackagedBaseOverride reads 0 changed lines;
  • the same for deleteMetaItem's inline refusal (12 lines) against refusePackagedBaseRemoval: 0 changed lines. The first lines of each pair match as a control;
  • automation.ts and protocol.ts carry identical blobs at f5ea0060 and e4006d68;
  • git diff f5ea0060 e4006d68 is the single changeset line.

① Derived judgments

Delta: the changeset sentence now reads "the same code and status the metadata door gives (403 NOT_OVERRIDABLE), and … the same sentence wherever the metadata protocol's own package door answers". RIGHT on both topologies.

  • On an environment kernel, the /automation doors relay the protocol door's exact refusal object.
  • On a host-config kernel, /meta's refusal comes from SysMetadataRepository.assertAllowed: the code and status agree, the sentence does not, and the wording claims exactly that.

The first record's finding is closed.

Carried forward, with the code unchanged:

  • (a) packagedBaseRefusal({ type, name, operation }), a new public method on ObjectStackProtocolImplementation: RIGHT. It is a faithful exposure of the /meta door's own locked-base verdict.
    • Both inline doors are lifted verbatim into private helpers that saveMetaItem and deleteMetaItem still call, at the same positions.
    • The helpers still throw. The method alone turns a 403 NOT_OVERRIDABLE / ITEM_LOCKED into a value, and re-raises everything else (pinned).
    • /meta's behaviour is unchanged. The one non-identical detail is a repeated pure registry read in deleteMetaItem, with no behavioural consequence.
  • (b) The lock keys on the server-held fact for the NAME: RIGHT. The verdict reads the registry's loader-registered PACKAGE_ID:NAME artifact entries (SchemaRegistry.getArtifactItem), never the request body or the engine's registered body. That is the 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 ruling's rules 1 and 2. It is pinned both ways: a packaged name is refused whatever the body claims, and a customer flow is written whatever the body claims.
  • (c) Refuse before mutate: RIGHT, on PUT /:name, DELETE /:name and POST / onto an existing packaged name. POST / is the seat-adopted bounded in-place fix: measured 200 with an overwrite before the fix, and refused after it.
    • The authoring gate runs first, then the lock, then the engine. On refusal, registerFlow / unregisterFlow are never entered (pinned on both topologies).
    • On DELETE, the lock precedes the engine's DELETE_RESTRICTED / 409.
    • The engine's own registration is untouched, so boot still registers packaged flows.
  • (d) What stays open: RIGHT, each as before and pinned:
    • customer-authored flows;
    • the clone door (a new name, and FLOW_CLONE_NAME_TAKEN on a held one);
    • the toggle door;
    • the OS_METADATA_WRITABLE=flow hatch, which opens both doors alike;
    • a composition with no protocol service, which is a declared decision. A wired slot that fails to resolve is re-raised, not fail-open.
  • (e) Code and status: RIGHT. 403 NOT_OVERRIDABLE is the producer's ledgered code, relayed unchanged through errorFromThrown, never re-stamped. No new code and no new status.
  • (f) The changeset: accurate. @objectstack/runtime patch and @objectstack/metadata-protocol minor, with Clause-②: yes (widening). It carries no stray tracker number and no model identifier, and it claims nothing the diff does not deliver.

Surface inventory:

  1. packagedBaseRefusal: a widening, and right.
  2. The three /automation definition doors refuse a packaged flow: a runtime security and integrity refusal that restores ADR-0126 §2. Not a clause-② narrowing.
  3. Nothing else: no route, no query set, no schema and no code. The test-only files are two protocol pins, the runtime pin and the dogfood pin.

② Semver level

  • minor for @objectstack/metadata-protocol (an added public member) is right.
  • patch for @objectstack/runtime is right. The refusal pulls the door back to the declared contract, which is the negative boundary of clause ②, so no BREAKING banner or ADR-0087 marker is owed.
  • (widening) is the right arm. The fixed version group keeps runtime and metadata-protocol in lockstep.

③ Boundary flags

  • Deviations: all nine are answered:
    • the adopted cross-lane surface and the yes (widening) re-declaration;
    • the POST / fix on the amended claim;
    • the dogfood and fold-population pins on the amended claim;
    • the mechanism assumptions, verified;
    • the verdict key, verified;
    • the four merges of main, with no overlap;
    • the tooling slip, which had no effect;
    • check-changeset-no-major run locally without a payload (CI's Check Changeset is green);
    • CI, now complete.
  • Out-of-scope findings:
  • Residuals, none blocking: an absent protocol slot keeps the doors open, which is declared and pinned. The checklist item's "EXPECTED FAIL today" notes go stale on landing, a checklist-author follow-up.

Implemented-by: claude/issue-20679-automation-door-package-lock
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

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

Development

Successfully merging this pull request may close these issues.

access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer

2 participants