Skip to content

fix(metadata-protocol): a packaged flow's refusal names the clone and the on/off switch, not a redeploy or OS_METADATA_WRITABLE (#20819) - #20909

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20819-regime-c-refusal-path
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20819-regime-c-refusal-path

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20819
Clause-②: no

What changes

When a write or a removal targets a flow that a code package ships, without naming that package with ?package=, the package doors in packages/metadata-protocol refuse it with 403 NOT_OVERRIDABLE. (A write that names the shipping package takes the named-base limb and is refused 403 ITEM_LOCKED, unchanged by this PR; corrected by the seat after review 5916639859.) The refusal's sentence is now chosen per ADR-0126 regime, from one table keyed on the type's declared regime:

  • PACKAGED_BASE_REGIME (protocol.ts:14704) is the regime declaration. It holds flow: 'C' and nothing else.
  • PACKAGED_BASE_REFUSAL_BY_REGIME (protocol.ts:14732) is the per-regime sentence table. It has one row, Regime C.
  • packagedBaseRegimeSentence (protocol.ts:14749) looks up the table. The two emitters, refusePackagedBaseOverride for save and refusePackagedBaseRemoval for delete, ask it for their sentence and fall back to their old literal, which is kept byte for byte. There is no type === 'flow' branch in the emitters.

The Regime C sentence, as served by PUT /api/v1/meta/flow/pkg_alert_flow:

Metadata item 'flow/pkg_alert_flow' is provided by a code package, and its packaged base is locked against in-place edits. Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, body {name, label}), or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; operator-only where one install serves several organizations). See docs/adr/0126-packaged-metadata-customization-model.md.

On removal, "against in-place edits" reads "against removal". The sentence names the clone (§7.1) and the switch (§7.2), cites ADR-0126, and does not mention OS_METADATA_WRITABLE or a redeploy.

What does not move (Clause-② no): the code, the status, and which writes are refused. The predicate is unchanged, and OS_METADATA_WRITABLE=flow still opens the lock at both doors (the existing hatch pin stays green; only its comment changed). The named-base ITEM_LOCKED limb is unchanged. Every type with no declared regime keeps its sentence byte for byte.

Zone 2 readings (PM hypotheses)

  • H1 held. packagedBaseRefusal is at protocol.ts:14513 (about :14506 at base 33b6e8bec). Every door reaches the same two emitters, and every caller relays the sentence unchanged:
    • saveMetaItem / deleteMetaItem call the emitters directly, behind environmentId !== undefined.
    • tenantAuthoredWriteRefusal returns packagedBaseRefusal for a flow in the loader's set. It is asked by saveMetaItem (the /meta PUT, on both topologies) and by runtime refuseUnauthoredFlowWrite (POST /automation, PUT /automation/:name, and the clone target). The runtime relays it through deps.errorFromThrown(refusal).
    • runtime refusePackagedFlowBaseChange covers DELETE /automation/:name, and PUT/POST when the protocol has no authoring rule. It relays through deps.errorFromThrown(refusal).
    • The REST /meta routes relay it through handleRouteError, verbatim below 500 characters.
    • The runtime pin "ONE emitter" compares the messages across the doors, and they are equal.
  • H2: no regime is declared in code. git grep -i regime over packages/{spec,metadata-protocol,metadata-core,runtime,core,types}/src finds prose comments only. DEFAULT_METADATA_TYPE_REGISTRY entries carry no regime field. So the smallest declaration went in beside the door: a map from type to regime, holding Regime C = flow.
    • ADR-0126 §3's full Regime C list: flow; permission; action (the disable half; the clone half is not chartered, §8 item 2); pre-charted tool, skill and position.
    • Two other Regime C types reach this door. action reaches it on save and delete. permission reaches it on save only: its delete is exempted by the supportsOverlay carve-out.
    • Neither is declared. The Regime C sentence names the /automation routes, which exist for a flow alone. action's switch is POST /api/v1/actions/_activation/:object/:action and it has no clone; permission has its own clone machinery.
    • tool, skill and position reach the door only if a package ships one. The measurement recorded in isNestedArtifactField's comment found none on the showcase.
    • The action control pin holds the undeclared sentence byte-identical. Measured at the REST route, see the Acceptance notes.
  • H3 held. Both routes are served on main: POST /automation/:name/toggle at automation.ts:2427 and POST /automation/:name/clone at automation.ts:2513, under the /automation domain prefix.
    • The toggle is gated by manage_metadata, then by refuseUngrantedActivationWrite, which requires the platform operator in the group / isolated postures (ADR-0126 §5).
    • The clone body { name, label } needs both keys. The toggle body is { enabled?: boolean }.
    • The runtime test's "what the lock leaves open" cases drive both routes to 200 on a packaged flow.
  • H4 applies, and it holds. CLIENT_MESSAGE_MAX = 500 (packages/rest/src/error-response.ts:72) truncates the tail at 500 characters.
    • The sentence is 411 characters before the name on save and 404 on removal: 425 and 418 with pkg_alert_flow. Any name up to 88 characters arrives whole.
    • The protocol pin asserts a length under 500. The REST pin asserts the served text still ends with the ADR citation.

Tests (all at head d58d5578f6 unless stated)

  • packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts, with 8 new cases:
    • flow save and delete, on both topologies: 403 NOT_OVERRIDABLE, clone and toggle named, ADR-0126 cited, no hatch, no redeploy, under 500 characters;
    • the plural spelling folds to the same sentence;
    • the /meta doors throw the regime sentence;
    • control: page keeps its sentence byte for byte;
    • control: action keeps its save and delete sentences byte for byte.
      Result: Tests 17 passed (17).
  • packages/runtime/src/domains/automation-packaged-base-lock.test.ts: PUT and DELETE /automation/:name (both topologies) and POST /automation onto a packaged name now also assert that the relayed message names clone and toggle and does not name the hatch. The hatch test's title and comment are corrected: the refusal no longer names the hatch, and the hatch still opens the door. Tests 15 passed (15).
  • packages/rest/src/rest-meta-packaged-flow-refusal.test.ts (new) drives the real PUT / DELETE /api/v1/meta/:type/:name routes over a real protocol and SchemaRegistry: flow PUT on both topologies, flow DELETE on an environment kernel, and a page control compared byte for byte. Tests 4 passed (4).
  • Full @objectstack/metadata-protocol suite, run at af8a9ee7a9 (the merge since then brought no change to this package): Test Files 193 passed | 3 skipped (196), Tests 2870 passed | 19 skipped (2889).
  • Typecheck at af8a9ee7a9: pnpm --filter typecheck passes for @objectstack/metadata-protocol, @objectstack/rest and @objectstack/runtime. tsc --listFiles counts each of the three test files once in its package's program. check:test-typecheck is OK for rest (0 held) and runtime (the identity-pinned ledger is unchanged).
  • The packages/cli tiers were not touched. The runtime and rest full suites are left to CI; this PR changes only the one test file in each.

Ablation on the regime choice (committed fix first; node scripts/ablation-replace.mjs with its restore trap, pnpm --filter @objectstack/metadata-protocol build, then node scripts/ablation-dist-preflight.mjs; rest and runtime read the dist/)

Predicted before running: leg A turns the flow pins red and leaves the controls green; leg B turns only the page controls red. Both came out as predicted.

leg on-disk proof dist preflight metadata-protocol runtime rest
A: delete flow: 'C', anchor 1→0, blob f1e6267e→787e1532 marker absent from all 24 built files 5 failed / 12 passed 5 failed / 10 passed 3 failed / 1 passed
A restore blob == HEAD f1e6267e, git diff HEAD empty marker present in 2 built files; tree clean 17 passed 15 passed 4 passed
B: declare page: 'C' anchor 1→0, blob f1e6267e→fbb29b3b marker present in 2 built files 1 failed (page control) / 16 passed 15 passed 1 failed (page control) / 3 passed
B restore blob == HEAD, git status --porcelain empty marker absent from all 24; tree clean 17 passed 15 passed 4 passed

The first leg-B attempt was a no-op: ablation-replace refused it because the replacement contained the anchor ("anchor count moved 1 → 1") and restored the file. It was re-run with a non-overlapping anchor, and the row above is that run. Both runs were at af8a9ee7a9.

Gates

node scripts/pm/dispatch-gates.mjs --commands (no paths), at d58d5578f6, derived 63 commands. The change set is 5 paths, 366 changed lines against the 5000-line human-merge threshold: under. All 63 were run at d58d5578f6, and --ran reports: ✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 61 run, 2 NOT-MEASURED (0 DERIVED from a recorded exit 3, 2 claimed).

  • 61 exited 0, including check:doc-authoring (the new sentence carries no tracker number), check:nul-bytes, check:engine-double-contract, check:cross-package-test-inputs, check:test-source-alias, check:issue-citations and check:durability-log-level.
  • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, reason: PREREQUISITE NOT MET (exit 3). Both read the whole workspace's built output. A full packages build is 50 uncached turbo tasks, @objectstack/console included (dry run), which is beyond the shared box's foreground cap. CI's lint.yml builds that closure first.
  • The branch merged origin/main (d58d5578f6) because the first derivation warned of a stale tree (scripts/pm/check-governed-queue-guard.mjs had moved). The incoming commits touch driver-memory and that script only. The re-derivation printed the same 63 commands with no stale warning. The build closure was refreshed and the three pinned files re-run at d58d5578f6 (17 / 15 / 4 passed).

Lint was narrowed to the changed files, not the repo-wide pnpm lint (that run is CI's). Three pieces of evidence make that narrowing complete:

  1. The population comes from eslint's own config: eslint --no-inline-config --print-config resolves a config for each of the 4 changed .ts files, so none is ignored.
  2. eslint --no-inline-config --format json on those files counts files 4 errors 0 warnings 0, at d58d5578f6.
  3. eslint.config.mjs never enables type-aware linting ("no parserOptions.project, no typed @typescript-eslint rules", at its line 328), so this diff cannot move the verdict on any file it did not touch.

Acceptance notes

Deviations

  • Commit trailers use the model-free pair from AGENTS.md, not the model-bearing trailer the harness suggested.
  • One merge commit (d58d5578f6) from origin/main; see Gates.

Generated by Claude Code

…and the switch

The package doors' NOT_OVERRIDABLE sentence is now chosen per ADR-0126
regime from one table keyed on the type's declared regime. A Regime C type
(flow) names its sanctioned paths, the clone under a new name and the
enable/disable switch, and cites ADR-0126; it no longer prescribes a
redeploy or the OS_METADATA_WRITABLE hatch. Every other type keeps its
sentence byte for byte. Code, status and the refused set are unchanged.

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

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 12 documentable anchor(s).

1 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))

⛔ 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), /api/v1/automation/:name/toggle (route, a path literal in ObjectStackProtocolImplementation), /automation/:name/toggle (route, a path literal in a comment in ObjectStackProtocolImplementation))

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
  • 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 — 11 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 165c1d49e3544494660eff2de6e9be3473be1832 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 74de859475b488f1cf9caeb263056082d9ac6c53 — the merge of head 88e3fcf451c16334089f8a676a28b642fdcda3e7 into base 165c1d49e3544494660eff2de6e9be3473be1832, 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 74de859475b488f1cf9caeb263056082d9ac6c53 && git checkout 74de859475b488f1cf9caeb263056082d9ac6c53
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 165c1d49e3544494660eff2de6e9be3473be1832 88e3fcf451c16334089f8a676a28b642fdcda3e7 && git checkout -B drift-repro 165c1d49e3544494660eff2de6e9be3473be1832 && git merge --no-ff 88e3fcf451c16334089f8a676a28b642fdcda3e7

node scripts/docs-audit/affected-docs.mjs --json 165c1d49e3544494660eff2de6e9be3473be1832

⚠️ 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 165c1d49e3544494660eff2de6e9be3473be1832 → 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: d58d5578f655999bc62f5884ae76d6d4e92ea51d
Local-runs: none

Inputs read: card #20819 (body; comments 5908835963, 5909059434, 5915426751, 5916420712); ADR-0126 §2, §3, §5, §7.1, §7.2 (and §8, §9, §10 where the diff cites them); ADR-0131 D6/D7/D13 with #15206; PR #20909 (body, file list, three-dot diff against main at the head above, merge base 8fec76a2b5); PR #20817 (4b45afaed, which introduced packagedBaseRefusal); the head's check-runs. Read-only throughout: the diff, git show of the head and of origin/main, REST reads of GitHub. The one arithmetic below (a character count) was done over the diff's string literal, not by running the package.

The PR head was d58d5578f6 at both reads (open, draft, base main).

Check-runs on the head, as read at the moment of this record (33 runs): 25 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke opt-in), 5 in_progress (Test Core 3/6, 4/6, 5/6, 6/6; Lint and Repo Gates), 0 failure. Check Changeset, the four Type Check jobs (workspace, source gates, debt ledger, consumer gates), Build Core, Test Core 1/6 and 2/6, Temporal Conformance, the three Dogfood Regression Gate shards, Dogfood Verify CLI and Governed Surface Queue Guard are success. Not waited on, not polled: the landing waits for the five to conclude, which is the seat's read, not this record's.

① Derived judgments

Accept set and public surface, from the diff (5 paths: protocol.ts +103/-4; one changeset; three test files, two of them new):

  1. Which writes are refused — unchanged: right. refusePackagedBaseOverride's predicate (artifactBacked && !overlayAllowed, then the named-base isWritablePackage limb, then the package-less throw) and refusePackagedBaseRemoval's (artifactBacked && !overlayAllowed && !legacyOverlayRemoval) are byte-identical to main; the only edit inside each if is the new Error(...) argument, now packagedBaseRegimeSentence(...) ?? (the old literal). isOverlayAllowed, which folds OS_METADATA_WRITABLE, is untouched, so the hatch still opens both doors: the runtime hatch pin stays green with only its title and comment changed. Code NOT_OVERRIDABLE and status 403 are unchanged at both emitters.
  2. The sentence, for a type with a declared regime (only flow) — a shipped-bytes change, not an accept-set change: right. PACKAGED_BASE_REGIME holds flow: 'C'; PACKAGED_BASE_REFUSAL_BY_REGIME has one row, C, a function of (type, name, operation); packagedBaseRegimeSentence folds the plural first through PLURAL_TO_SINGULAR, so flows gets the same sentence (pinned). The emitters carry no type === 'flow' branch: the only type-keyed step is the map lookup. The delete variant differs from save in one clause (against removal. for against in-place edits.).
  3. Every other type keeps its sentence byte for byte: right. The fallback literals are the pre-PR literals unchanged (the diff wraps each in ?? ( and ) and touches no character of it). Pinned as controls: page at the protocol and at the real REST /meta route; action, save and delete, at the protocol.
  4. Public surface — nothing added: right. PackagedBaseRegime is a module-scoped, non-exported type alias; the two tables and the lookup are private static. No export, no request or response shape and no error code moves. The refusal text reaches consumers as an error string; that is shipped bytes, not a published accept set.
  5. The new sentence's facts, checked against origin/main:
    • POST /api/v1/automation/:name/clone exists (automation.ts:2513), body { name, label } with both keys mandatory and unknown keys refused; gated by manage_metadata (the clone arm of isFlowAuthoringWrite). True.
    • POST /api/v1/automation/:name/toggle exists (automation.ts:2427), body { enabled?: boolean }; gated by manage_metadata (the toggle arm) and then by refuseUngrantedActivationWrite, which under a posture that enforces a wall refuses every caller whose posture rung is not PLATFORM_ADMIN. So "operator-only where one install serves several organizations" is true, and {enabled: false} is the toggle's off contract. True.
    • It cites docs/adr/0126-packaged-metadata-customization-model.md and names neither OS_METADATA_WRITABLE nor a redeploy. True.
    • Length: 411 characters before the name on save, 404 on removal (425 and 418 with pkg_alert_flow), counted over the diff's literal. CLIENT_MESSAGE_MAX = 500 (packages/rest/src/error-response.ts:72) truncates at length 500 and over, so a name of up to 88 characters arrives whole on save (95 on removal). The PR body's quoted sentence is the template's output byte for byte. The ADR citation is the tail, so the REST pin's ends-with-.md. assertion is the right one. True.
  6. Triage's direction (5908835963), against the diff:
    • A Regime C refusal names the clone under a new name and the operator-gated switch, cites ADR-0126, and does not mention OS_METADATA_WRITABLE: met, for save and delete, at the protocol, at PUT and DELETE /api/v1/meta/flow/:name (real REST routes over a real protocol and SchemaRegistry), and at PUT and DELETE /api/v1/automation/:name plus POST /api/v1/automation onto a packaged name, whose relay pins now assert the two paths and the absence of the hatch.
    • Chosen per regime from one table keyed on the type's regime, no flow special case in the door's prose: met in letter; the spirit is judged in ③.
    • Other regimes byte-identical: met (item 3).
    • The hatch's fate stays feat(metadata-core,metadata-protocol,objectql,plugin-security): the sys_metadata family goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206's: met. isOverlayAllowed, orgScopedWriteRefusal, the two code-only refusals and the ITEM_LOCKED limb are untouched; the PR removes the hatch from one sentence and from nothing else.
  7. PR fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) #20817's lift is preserved. The runtime "ONE emitter" pin still compares the /automation answer to saveMetaItem's and deleteMetaItem's thrown refusal, so the doors cannot drift on the new sentence either.

② Semver level

  • .changeset/20819-regime-c-refusal-path.md declares '@objectstack/metadata-protocol': patch and carries the PR's Clause-② line, reading no. Right. The only non-test path is packages/metadata-protocol/src/protocol.ts; @objectstack/metadata-protocol is a published package (17.5.0, not private), and a bug fix in a released package takes patch. packages/rest and packages/runtime change only test files, which publish nothing, so they need no entry. No skip-changeset.
  • The Clause-② reading of no is right: the refused set does not move (① item 1), no public surface is added (① item 4), and a refusal's prose is shipped bytes, not a published accept set. The changeset, the PR body and the claim (5915426751) all carry the same reading. Check Changeset is success on the head.
  • Changeset faces, sentence by sentence. The title line: true. "A write or removal that targets a flow shipped by a code package is refused with 403 NOT_OVERRIDABLE": true for the package-less write the next sentence enumerates (PUT and DELETE /api/v1/meta/flow/:name, the three /automation doors); over-broad by one shape — a write that names the shipping package (PUT /api/v1/meta/flow/:name?package=PKG, PKG booted) answers 403 ITEM_LOCKED with the hatch prescription instead, unchanged by this PR and by design of the earlier rulings on that limb (③ item 5). "The refusal used to say ... and cited ADR-0005": true. "The administrator of an installed package can do neither": true (ADR-0131 D6). The two path bullets: true (① item 5). "The status, the code and which writes are refused are unchanged": true. "OS_METADATA_WRITABLE=flow still opens the lock as before; the refusal just no longer suggests it": true. "Every other metadata type's refusal reads exactly as before": true at the package doors. No model identifier; no tracker number in the runtime string.
  • PR body faces. Every line number checked is right at the head: packagedBaseRefusal 14513, PACKAGED_BASE_REGIME 14704, PACKAGED_BASE_REFUSAL_BY_REGIME 14732, packagedBaseRegimeSentence 14749; error-response.ts:72; automation.ts:2427 and :2513 on main. H1 (every caller relays the sentence unchanged: tenantAuthoredWriteRefusal at 14610 returns packagedBaseRefusal for a flow in the loader's set, asked by saveMetaItem at 16678 and by runtime refuseUnauthoredFlowWrite for POST /automation, PUT /automation/:name and the clone target; refusePackagedFlowBaseChange covers DELETE /automation/:name; both relay through deps.errorFromThrown): true. H2 (no regime is declared in code; git grep -i regime over the six packages finds comments only; DEFAULT_METADATA_TYPE_REGISTRY in packages/spec/src/kernel/metadata-plugin.zod.ts carries no regime field): true. action reaches the door on save and delete; permission on save only, its supportsOverlay: true putting it in OVERLAY_CAPABLE_TYPES so legacyOverlayRemoval exempts its delete: true. action's switch is POST /actions/_activation/:object/:action with no clone chartered (ADR-0126 §8 item 2); permission has clone_permission_set: true. Tool, skill and position: the isNestedArtifactField measurement lists them among the types that ship no artifacts on the showcase: true. H3 and H4: true (① item 5). The merge: one merge commit, second parent 8fec76a2b5; the 27 merged-in paths are packages/drivers/driver-memory/**, that driver's own changeset, and scripts/pm/check-governed-queue-guard.mjs, so "driver-memory and that script only" holds. Local test counts, the ablation legs and the 61 local gate exits are the dev's local claims; the head's check-runs are the verdicts and are recorded above. Commit trailers: the model-free pair on each of the three authored commits; the PR body footer is the session-URL form; both as AGENTS.md prescribes.

③ Boundary flags

The regime declaration — the three questions.

  1. Can the table's shape carry per-type sanctioned paths for action and permission without a flow special case? Not as it stands. The table is regime to sentence, and its one Regime C row hard-codes the flow's two routes. action's sanctioned paths are a different switch route and no clone; permission's are its own clone action and, until ADR-0126 §8 item 3 converges, no ledger switch. So the Regime C sentence must vary by type, which needs a second axis: a per-type sanctioned-path table (switch route, clone route, each optional) that the Regime C sentence reads, keyed off the same declaration. That is declared data, not a type === 'flow' branch in prose, so the rule survives the extension; but the extension is required before either type can be declared, and it is not this card's (triage: other types unchanged). Judged: the shape is right for one row and must grow a paths axis for the next; the dev's doc comment says exactly this.
  2. Does leaving them out keep "no flow special case" in spirit? Yes, with the debt named plainly. Declaring action: 'C' today would emit the /automation routes for an action, a false prescription, which ADR-0126 §2 ("the refusal naming the sanctioned path") forbids harder than the omission does. But with one row and one member, PACKAGED_BASE_REGIME is extensionally a flow table, and its own comment concedes the criterion is per-type ("declared only once the Regime C sentence's paths are ITS served paths"). So the map is not ADR-0126 §3's assignment, which puts action and permission in C; it is the refusal's sanctioned-path declaration wearing the regime's name. Acceptable for this card. The follow-up that adds action and permission should give it the paths axis from item 1, or rename it to what it is.
  3. Where does a declaration of regimes belong? ADR-0126 §2 already says: a new type "must declare its regime at admission, in its registry-entry comment". The registry is DEFAULT_METADATA_TYPE_REGISTRY in packages/spec/src/kernel/metadata-plugin.zod.ts, beside supportsOverlay, allowOrgOverride and allowRuntimeCreate. A code declaration belongs there as a registry field, read by protocol.ts the way OVERLAY_ALLOWED_TYPES is derived from allowOrgOverride today. That is a packages/spec schema change: its own card, with the generated baselines and its own Clause-② read. Beside the door is a stopgap for this card, and this PR is not asked to move it.

Dev flags.

  1. Regime C action and permission refusals still name the hatch (measured by the dev at the REST /meta route in-process). True by reading: both fall through to the unchanged literal at protocol.ts:14853 to :14857, and the action control pins it byte for byte. Consistent with the card's scope (flow) and triage's "other types unchanged". Escalated to the seat to file as the dev's first out-of-scope finding (class b, reach measured): the action sentence must name POST /api/v1/actions/_activation/:object/:action and no clone; the permission sentence its clone action and no switch until §8 item 3; and both need the paths axis of item 1 first.
  2. The named-base ITEM_LOCKED limb still prescribes the hatch for any type, flow included; reach not measured. A packaged flow can reach it with the hatch sentence, by reading, on both topologies. PUT /api/v1/meta/flow/NAME?package=PKG threads ?package= as packageId into saveMetaItem; on an environment kernel refusePackagedBaseOverride takes the namedBase && !isWritablePackage(PKG) limb before the regime sentence, and on a host-config kernel tenantAuthoredWriteRefusal hands the same packageId to packagedBaseRefusal, which reaches the same limb. isWritablePackage (package-writability.ts) answers false for every booted code package (engine.manifests.has(PKG)) and for read-only manifest scopes. The emitter is SysMetadataRepository.readOnlyBaseOverrideError(type, PKG, false): 403 ITEM_LOCKED, "Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE=flow to grant a runtime escape hatch on this TYPE ...", citing ADR-0010. The dev's harness missed it because its engine double answers every package writable; on a booted app the reach is real. Does it contradict the PR? Not the PR body, which scopes its claim to the package-less NOT_OVERRIDABLE door and names this limb as unchanged; it does make the PR title and the changeset's opening sentence read broader than the diff (②). It is the card's contract (ADR-0126 §2, for flow) unmet at a second emitter, for a caller who names the base. Outside the card's direction (the NOT_OVERRIDABLE sentence), disclosed by the dev, and not a one-line fix: the emitter's remedy pair (hatch closed, hatch open) is a ruled shape shared with the repository door, and feat(metadata-core,metadata-protocol,objectql,plugin-security): the sys_metadata family goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206 (ADR-0131 D6) may retire the hatch it names. Escalated to the seat: file it (class b, reach by reading, unmeasured on a booted app) or fold it into item 4's card, and decide together with feat(metadata-core,metadata-protocol,objectql,plugin-security): the sys_metadata family goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206 whether the named-base flow write gets a regime-aware sentence or the hatch goes. Not a FAIL here.
  3. orgScopedWriteRefusal and the code-only refusals also name the hatch. True by reading (protocol.ts:14330; :14211 and :14230). Different conditions (an org-scoped write of any static-registry type; code-only types), and their fate is feat(metadata-core,metadata-protocol,objectql,plugin-security): the sys_metadata family goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206's. Accepted.
  4. check:dual-build-cjs-loads and check:type-check-debt NOT MEASURED locally. CI's: Type Check · debt ledger is success on the head; Lint and Repo Gates was in_progress when read. Recorded, not waited on.
  5. One merge of main. One merge commit (d58d5578f6, second parent 8fec76a2b5), an operation AGENTS.md describes for a task branch; the incoming commits touch no package this diff touches, so the scoped re-check (the three pinned files re-run at the head, per the dev) is the prescribed shape. Accepted.
  6. open_questions: none filed.

Implemented-by: claude/issue-20819-regime-c-refusal-path
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

…e and removal

A write naming the shipping package with ?package= takes the named-base
limb and is refused ITEM_LOCKED, unchanged by this change; say so.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 88e3fcf451c16334089f8a676a28b642fdcda3e7
Local-runs: none

Delta review after the at-tier record 5916639859 on d58d5578f6 (PASS). Inputs read, these and nothing else: record 5916639859 in full; the two-dot diff d58d5578f6..88e3fcf451 (git fetch of claude/issue-20819-regime-c-refusal-path into a review ref, then git diff and git show); .changeset/20819-regime-c-refusal-path.md in full at the head; the PR body as it now reads; the head's check-runs by REST. To judge ① against what the code does rather than what the prose says, the two emitters and their callers were read at the head with git show; the code there is byte-identical to d58d5578f6 (③ item 1). Read-only throughout: nothing built, run or re-run.

The PR head was 88e3fcf451c16334089f8a676a28b642fdcda3e7 at both reads (open, base main). The delta is one commit, 88e3fcf451 chore(changeset): scope the Regime C refusal to the package-less write and removal, carrying the model-free trailer pair.

Check-runs on the head, as read for this record (38 runs): 16 success, 5 skipped, 17 in_progress, 0 failure. Success: Check Changeset (two runs, both success), Check PR Size, Auto Label, filter, Governed Surface Queue Guard, Check Documentation Links, Flag docs affected by code changes, and the four claim guards (No other open PR may claim the same issue; The card this PR closes must claim this branch; Part-of PR must not also close its card; No other open PR may claim the same single-writer path), each of those four twice. Skipped: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in), and one run each of Auto Label and Check PR Size. In progress: Build Core, Test Core 1/6 to 6/6, the four Type Check jobs (workspace, source gates, debt ledger, consumer gates), Lint and Repo Gates, Temporal Conformance (live PG + MySQL), Dogfood Verify CLI, Dogfood Regression Gate 1/3 to 3/3. The doubled names are consistent with the PR-body edit re-firing pull_request workflows on the same head. Not waited on, not polled: the landing waits for the seventeen to conclude, which is the seat's read, not this record's.

① Derived judgments

The seat asked for one correction to the changeset's opening sentence (5916639859 ②): a write that names the shipping package is refused 403 ITEM_LOCKED by the named-base limb, not NOT_OVERRIDABLE. Judged sentence by sentence at the head, against the emitters as they stand.

  1. The opening sentence, corrected: true. "A write or removal that targets a flow shipped by a code package, and does not name that package, is refused with 403 NOT_OVERRIDABLE." refusePackagedBaseOverride (protocol.ts:14776) tests the named-base limb first (:14845, namedBase && !isWritablePackage(packageId)) and only then throws the regime sentence with NOT_OVERRIDABLE / 403 (:14859), so the package-less write answers NOT_OVERRIDABLE. refusePackagedBaseRemoval (:14885) takes { type, name } only, packagedBaseRefusal's delete arm (:14518) hands it exactly that, and deleteMetaItem's request type (:22597) has no packageId field at all: a removal cannot name a package at this door, so every removal answers NOT_OVERRIDABLE. The qualifier "does not name that package" is therefore true of the write and vacuous of the removal, not false of it.
  2. The enumeration: true, door by door. "PUT /api/v1/meta/flow/:name without ?package=": the REST PUT reads req.query.package (rest-server.ts:6908) and spreads it into saveMetaItem's request as packageId, so the qualifier belongs on this door and on no other. "DELETE /api/v1/meta/flow/:name", unqualified: the REST deleteRequest is built from type, name, organizationId, environmentId, parentVersion, actor, state and dropStorage, with no package, and the protocol has no slot for one. "PUT and DELETE /api/v1/automation/:name, and POST /api/v1/automation", unqualified: refusePackagedFlowBaseChange calls packagedBaseRefusal({ type, name, operation }) (automation.ts:1506) and refuseUnauthoredFlowWrite calls tenantAuthoredWriteRefusal({ type, name, item }) (:1548), neither with a packageId, and tenantAuthoredWriteRefusal forwards one only when given (protocol.ts:14615). None of the three /automation doors can reach the named-base limb.
  3. The appended sentence: true. "A write that names the shipping package with ?package= is refused with 403 ITEM_LOCKED by a separate limb, which this change leaves as it was." The limb is :14845 to :14849; its emitter is SysMetadataRepository.readOnlyBaseOverrideError (sys-metadata-repository.ts:1745), code ITEM_LOCKED, status 403, lockSource: 'package'. The shipping package is a booted code package, and isWritablePackage (package-writability.ts:77) answers false for every id in engine.manifests, so the named write takes this limb. "Leaves as it was": the delta touches no code, and 5916639859 ① item 1 judged the limb byte-identical to main on the PR's three-dot diff.
  4. "The refusal" keeps its referent. The new sentence sits last in the final paragraph, after "The refusal used to say", "The refusal now names" and "the refusal just no longer suggests it". Each of those still refers to the NOT_OVERRIDABLE refusal the opening sentence introduces, and the ITEM_LOCKED sentence names its own code and calls its limb "separate". No sentence between them changed, so no referent moved.
  5. No other sentence became wrong or ambiguous. The title line, the two path bullets, "The status, the code and which writes are refused are unchanged", "OS_METADATA_WRITABLE=flow still opens the lock as before", "Every other metadata type's refusal reads exactly as before": each was judged true in 5916639859 ② and each is unchanged in the diff, as is the code it describes.
  6. The PR body's correction matches. Its first paragraph now carries the same statement in parenthesis ("A write that names the shipping package takes the named-base limb and is refused 403 ITEM_LOCKED, unchanged by this PR"), and the body edit moved no commit: the head's only new commit is the changeset one.

② Semver level

  • .changeset/20819-regime-c-refusal-path.md still declares '@objectstack/metadata-protocol': patch and still carries Clause-②: no. Neither line is in the diff; its two hunks are the opening paragraph and the closing paragraph. 5916639859 ② judged both right on grounds the delta does not touch: the only non-test code path is protocol.ts in a published package, the refused set does not move, and a refusal's prose is shipped bytes, not a published accept set. The correction narrows a face; it moves no reading. Check Changeset is success on the head, in both of its runs. Unchanged, and right.

③ Boundary flags

  1. The delta touches nothing else. git diff --stat d58d5578f6..88e3fcf451 is one path, .changeset/20819-regime-c-refusal-path.md, 2 insertions and 2 deletions, one commit. Every code path, every test file and every line number 5916639859 cites is byte-identical at this head. Every other item of 5916639859 carries over unchanged: ① items 1 to 7 (the predicates, the regime table and lookup, the fallback literals, the public surface, the new sentence's facts, triage's direction, PR fix(runtime,metadata-protocol): the /automation write doors keep the packaged-base lock the /meta door keeps (#20679) #20817's lift); ② (level, Clause-②, the PR-body faces, the merge of main, the trailers); ③ items 1 to 9 (the regime declaration's three questions, the action and permission hatch sentences escalated for filing, the named-base limb's own hatch prescription escalated to the seat together with feat(metadata-core,metadata-protocol,objectql,plugin-security): the sys_metadata family goes tenant-less; the per-organization overlay axis retires; managed content is sealed (ADR-0131 D6/D7/D13) #15206, orgScopedWriteRefusal and the code-only refusals, the two NOT-MEASURED gates left to CI, the merge, no open questions). The PR title reads as before, and 5916639859 ③ item 5's remark on it stands; the seat's ask was the changeset sentence, and that is what moved.
  2. One precision the seat may take or leave. The limb's exact predicate is "names a read-only package", not "names the shipping package": a write naming some other booted code package also answers ITEM_LOCKED, for that package. The changeset's opening sentence would file that misdirected write under NOT_OVERRIDABLE. The enumeration is exact ("without ?package="), the shape the seat asked to see stated is stated truly, and no sentence claims the misdirected case by name, so this is a face for a later edit, not a FAIL.
  3. Check-runs: seventeen in_progress at read, recorded above; their conclusions are the gate verdicts and are not this record's to wait on.
  4. open_questions: none filed.

Implemented-by: claude/issue-20819-regime-c-refusal-path
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 18:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 4d0b9cd Sep 30, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20819-regime-c-refusal-path branch September 30, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants