Skip to content

fix(security)!: under a wall the /packages verbs that publish, discard, roll back, revert, duplicate or adopt catalog items are the platform operator's (ADR-0131 D3) - #23003

Merged
objectstack-fleet[bot] merged 37 commits into
mainfrom
claude/issue-15204-wall-catalog-package-doors
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 37 commits into
mainfrom
claude/issue-15204-wall-catalog-package-doors

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Part of #15204

Clause-②: yes (narrowing)

Stage W3 of #15204, measured in 6117740670 and accepted for build by the seat. This is the walled catalog refusal at the remaining /packages write doors. W (#22895) refused catalog writes at /meta, the clone door and the data door. W2 (#22935) refused them at install-local and at DELETE /packages/:id. This PR refuses the seven package verbs that still published, discarded, rolled back, reverted, copied or adopted the operator's catalog items behind manage_metadata alone.

The ruling this executes

ADR-0131 D3: "tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)". Under group / isolated, below the operator rung (and not isSystem), each door refuses when the items its verb acts on include a position, a permission set or a capability. That is the placement (a) to (e) of 6117740670.

Measured first

The harness is the W2 one: a booted examples/app-crm stack (bootStack, multiTenant: 'posture-only') with two organizations.

  • Organization A's owner holds manage_metadata inside organization A (rung TENANT_ADMIN).
  • The operator published a permission set in a writable runtime package (commit c1).
  • Organization B's member holds that set.

Isolated and group answered identically:

Door (A, below the operator) Answer B afterwards
POST /packages/:id/publish-drafts (the operator's pending draft adds manage_users) 200 gains manage_users
POST /packages/:id/rollback {commitId: c1} 200 loses it
POST /packages/:id/commits/:c3/revert (the operator's re-publish) 200 loses it
POST /packages/:id/duplicate 200; the set is copied under the same name into A's package, and GET /meta/permission/:name then serves A's copy unchanged
POST /packages/:id/revert (discard drafts) 200; the operator's pending catalog draft is gone unchanged
POST /packages/:id/discard-drafts (not measured; found by contract review 6121612552) same population and per-draft step as revert unchanged
POST /packages/:id/adopt-orphans 200; environment catalog rows, the operator's included, are rebound into A's package unchanged

By reading, commits/:commitId/revert passes only the commit id to revertCommit; the URL's :id is never read. 17.7.0 (4e4e881427) carries all seven doors, each gated by requireManageMetadata alone (discard-drafts at 4e4e881427:1792).

What changed

  • One helper: refuseWalledCatalogPackageWrite in packages/runtime/src/domains/packages.ts generalises W2's refuseWalledCatalogPackageDelete, and the W2 delete now uses it.
    • It asks walledCatalogWriteVerdict FIRST. Its answer does not vary across catalog types, so when it admits the caller (under single, with no tenancy service, for isSystem, or for the PLATFORM_ADMIN rung) nothing is read, and the door behaves as before.
    • Only for a refused caller does it read the items the verb acts on, and it refuses 403 PERMISSION_DENIED naming the posture when they include a catalog type.
  • What each door judges:
    • publish-drafts, discard-drafts and revert: the package's sys_metadata draft rows.
    • rollback: the items of every apply commit of the target's package at or after the target's instant, a superset of the reverted ones. Read as rollbackToPackageCommit reads them: the target row, then listCommits.
    • commits/:commitId/revert: that commit's own items, whatever package the URL names.
    • duplicate: the rows duplicatePackage copies (active, organization-less).
    • adopt-orphans: the orphans exactly as reassignOrphanedMetadata selects them (organization-less rows with a null, empty or sys_metadata package).
  • ADR-0087: the semantic entry walled-catalog-package-verbs-retired. The changeset carries FROM → TO per door.
  • Census: row 55b names the eight /packages write doors (the delete included) and the three catalog types; its anchor moves to refuseWalledCatalogPackageWrite. The one isSystem read moved into the helper, and none was added.
  • Docs: api/metadata-api.mdx (each verb and what it is judged by) and permissions/permission-sets.mdx (the verbs that publish, discard, roll back, revert, duplicate or adopt catalog items, discard-drafts included).

Tests

  • Pins: wall-package-doors-refusal.dogfood.test.ts, 23 tests.

    • Under each of isolated and group, these are refused 403 PERMISSION_DENIED naming the posture:

      • publish-drafts;
      • revert and discard-drafts (the operator's draft stays);
      • rollback;
      • commit revert, and the same commit under a URL naming A's own package;
      • duplicate (nothing copied);
      • adopt-orphans (the orphan stays unbound).

      Each pin compares B's resolution across its own request only.

    • Controls: A runs every door but adopt-orphans on its own catalog-free package as before (that verb reads the environment's orphans, and this environment carries the operator's catalog orphan); the operator runs every door, adopt-orphans included, on the catalog-carrying one.

    • single control: the same manage_metadata holder runs them all as before.

  • Ablation, one per door: each refusal was forced to admit (isSystem passed to that call site alone), runtime rebuilt, and the marker confirmed in dist/ by ablation-dist-preflight. Exactly that door's pins went red under both postures: publish-drafts 2, discard-drafts 2, revert 2, rollback 2, commit revert 4, duplicate 2 (re-run after the duplicate reader narrowed), adopt-orphans 2. Everything else stayed green, W2's 19 pins included. Each was restored to the HEAD blob and rebuilt, and all seven markers are absent from dist/.

  • Suites and gates on this head f10bbe8e8a (main 0ede0f8ab3 plus W3):

    • the three wall pin files: 59/59 (W3 23, W2 19, W 17);
    • check:system-context-census OK;
    • runtime: 359 files / 5,884 passed, 19 skipped;
    • dispatch-gates --commands: 110 of 110 exit 0;
    • check-commit-card-trailers over the three new commits: clean.
  • Round-1 finding, fixed: check:query-options-erasure refused two as any casts on the helpers' engine reads, which now carry typed options.

Acceptance notes

  • Not decided here (the maintainer's): whether a walled organization's manage_metadata may author or publish the environment's non-catalog metadata through these doors. A package whose drafts, commits or rows carry no catalog item is handled as before.
  • adopt-orphans under a wall: any environment catalog orphan refuses the whole adoption, since the verb rebinds every orphan at once. The operator adopts them, or a catalog-free environment adopts as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP

claude added 30 commits October 11, 2026 19:39
… door (ADR-0131 D3)

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
… with single and assignment controls (ADR-0131 D3)

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
…087 semantic entry; census row 55b

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
…ll-catalog-refusal

# Conflicts:
#	content/docs/permissions/system-context.mdx
…efusal (ADR-0131 D3)

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
… row 55b names the clone door

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
…y name the clone door

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
…ace: exact doors, capability's unchanged accept set, the D12 delegate

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
…he clone door and the Setup env-door write (ADR-0131 D3)

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

Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-authored-by: Claude <noreply@anthropic.com>
Resolve content/docs/permissions/system-context.mdx: keep row 55b (this
branch) and main's row 58 rewording (#22875); counts re-derived with
pnpm gen:system-context-census.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…refusal' into claude/issue-15204-wall-package-refusal
…ADR-0131 D3)

Under group/isolated, below the platform operator rung, install-local
refuses an install whose incoming version declares a position, permission
set or capability, an upgrade where either version declares one, and an
uninstall of a package whose installed version declares one; DELETE
/packages/:id refuses a package holding stored catalog rows. Both ask
walledCatalogWriteVerdict once per declared type.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…talog-package-writes-retired

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…log items; alias objectql in cloud-connection tests

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…package-refusal

Resolve content/docs/permissions/system-context.mdx: keep this branch's
row 55b (it names the /packages delete refusal); counts re-derived with
pnpm gen:system-context-census.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…nd permission-set provenance pages

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
… wall (ADR-0131 D3)

publish-drafts, revert, rollback, commits/:commitId/revert, duplicate and
adopt-orphans ask walledCatalogWriteVerdict through one helper generalising
the W2 delete refusal, each judging the items its verb acts on.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…talog-package-verbs-retired; docs

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…refusal' into claude/issue-15204-wall-catalog-package-doors
Resolve content/docs/permissions/system-context.mdx: main's count-free
prose (#22950), this branch's row 55b; no count lines.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…refusal' into claude/issue-15204-wall-catalog-package-doors
`--check-drift` takes the run's event (`--event`), required. On `schedule`,
the profile scripts/test-shard-timings.json is measured on, a DRIFT exits 1
as before; on every other event it prints the same text, raises one
`::warning::` annotation and exits 0. A missing or empty event is refused.

Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2
Co-authored-by: Claude <noreply@anthropic.com>
…one, with a 20s floor

The ceiling check takes the run's event (`--event`, required). On
`schedule`, the profile the ceilings were measured on, a package over its
ceiling reds the step as before; on every other event the same table
prints, each package over its ceiling is a `::warning::` annotation, and
the step exits 0. Refusals stay red on every event.

A package whose effective ceiling is under CEILING_FLOOR_SECONDS (20s) is
never red and prints "below floor"; the header records the floor's reading.

ci.yml passes `--event "$GITHUB_EVENT_NAME"` to both timing steps, which
keep no `if:` and no `continue-on-error`.

Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2
Co-authored-by: Claude <noreply@anthropic.com>
…refusal' into claude/issue-15204-wall-catalog-package-doors
…204-wall-catalog-package-doors

The four conflicted files are main's copy of W2's head, which this branch
already carried; this branch's W3 versions are kept.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 12, 2026
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

22 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 9f4b652e3a488f4351b47efdea93202033c064f7.

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

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 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 9f4b652e3a488f4351b47efdea93202033c064f7 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9f4b652e3a488f4351b47efdea93202033c064f7

⚠️ 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 9f4b652e3a488f4351b47efdea93202033c064f7 → 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: 0e4fdb4e7963be8f0d92a7f392360d014852de0f
Local-runs: none

Full record on this PR (stage W3 of #15204, the walled catalog refusal at the remaining /packages write doors, ADR-0131 D3). Reviewed read-only: the PR body, its 7-file list (+547 / −42), the net diff of 0e4fdb4e79 against main (merge base 48cca4ae00, the commit W2 #22935 and #22939 landed under; main read at 6a7eca6868, three commits past the base — 74c4b3435e, 147a0d1acc, 6a7eca6868 — none touching any of the PR's seven paths, only sibling semantic entries in the same directory; mergeable true), the 34 commits of the branch (W2's own commits and the merges of main sit beneath the merge base, so the three-dot diff holds exactly the seven files), card #15204's body and all 176 comments through the dev's addendum 6121258157 (among them the W3 measurement 6117740670, the seat note 6117751450, the W2 ruling 6114823168, the 17.7.0 measurement 6114989076, the Landed records 6117358377 and 6121083900, the next-prerelease rulings 6117174845, 6117531327 and 6119234024, and the D3 pointer 6070695107), W2's contract review 6117680834 on #22935 (its ③ 2 is the escalation W3 answers), ADR-0131 D3 and D6 and ADR-0087 (the launch-window section and the disposition vocabulary) as served on main, and on main: walledCatalogWriteVerdict (meta-write-capability.ts), readEffectiveTenancyPosture, the dispatcher's resolveServiceOrLoud and classifyService, every branch of packages/runtime/src/domains/packages.ts at the merge base and at the head, the precedents 06cc45dc36 (W) and 4806b18aea (W2) with W2's refuseWalledCatalogPackageDelete, changeset, entry and dogfood pins, protocol.ts's publishPackageDrafts, discardPackageDrafts, discardDraftInItsScope, revertStoredPackage, duplicatePackage, reassignOrphanedMetadata, listCommits, revertCommit and rollbackToPackageCommit, the sys_metadata_commit object, MetadataManager.publishPackage, the REST package registrar and its route ledger, the census page's #22950 state, and the 17.7.0 tree at 4e4e881427. Nothing was built, run, re-run or ablated.

Gate verdicts on the head, read 2026-10-12T09:35Z. 34 check-runs: 32 completed — 30 success, 2 skipped (the roster skips Console Pin Gate and Packed-tarball smoke (opt-in)) — and 2 still in_progress, named here as pending: Test Core (3/6) and Test Core (4/6); their conclusions are the gate verdicts when they land, and this record does not pre-empt them. Green among the completed: Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (1/6), Test Core (2/6), Test Core (5/6), Test Core (6/6), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter. Where a judgment below says "pinned", the pin is one of the green runs: the dogfood shards carry wall-package-doors-refusal.dogfood.test.ts (21 pins) beside W2's 19 and W's 17, Lint & Repo Gates carries check:system-context-census, Check Changeset carries check-adr-0087-registration.mjs and check-changeset-no-major.mjs, and Type Check · source gates carries @objectstack/spec's entry gates — not a local run. The PR is a draft, mergeable true, mergeable_state blocked; no review is submitted on it.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named right or wrong.

  1. The six measured doors (brief point a). One helper, refuseWalledCatalogPackageWrite, generalises W2's refuseWalledCatalogPackageDelete (which it replaces; the W2 delete now calls it with the same stored-row reader). It resolves the effective posture through readEffectiveTenancyPosture, asks walledCatalogWriteVerdict FIRST for SECURITY_CATALOG_TYPES[0] with isSystem: ec?.isSystem === true and callerPosture: ec?.posture — the operator rung off the execution context, never a held capability — and returns null without calling readTypes when that answer admits (single, no tenancy service, isSystem, PLATFORM_ADMIN). Only a refused caller's items are read, and the first security-catalog type among them answers deps.error(…, 403). At each door the call sits after requireManageMetadata and before the first write:

    • publish-drafts: before resolveProtocol and publishPackageDrafts; reads sys_metadata rows { package_id: id, state: 'draft' }. publishPackageDrafts promotes repo.listDrafts({ packageId }), the environment repository's organization-less drafts — the reader is that population or a superset (a legacy organization-scoped draft, which the verb skips). Right.
    • revert (discard drafts): before revertStoredPackage; the same draft reader. revertStoredPackage discards exactly listDrafts({ packageId }) through discardDraftInItsScope. Right for this door — and see item 2 for its sibling.
    • rollback: after the body parse, before rollbackToPackageCommit; reads the target sys_metadata_commit row by id, then protocol.listCommits({ packageId: target.package_id }), and keeps every commit other than the target whose operation is apply and whose createdAt is not earlier than the target's instant. rollbackToPackageCommit reverts, through revertCommit, exactly the apply commits strictly newer than the target of that same package, read the same way (findOne by id, then listCommits); listCommits returns id, operation, parsed items and createdAt, the fields the reader uses. The reader is therefore the reverted set or a superset (equal instants; an unparseable instant folds to "included"). The operation === 'apply' filter is exact: rollback never undoes a revert commit. Right, and the "superset of the reverted ones" claim in the body and the helper's comment is true.
    • commits/:commitId/revert: before revertCommit; reads the commit row by id and its items (JSON text, parsed by commitItemTypes, which also accepts an already-parsed array). revertCommit reads that row by id and reverts those items; the URL's :id is read by neither, as the measurement said — pinned under a URL naming A's own package. A missing commit reads as touching nothing and the verb answers its own COMMIT_NOT_FOUND 404. Right.
    • duplicate: after requireDuplicableSource, before duplicatePackage (whose first write is the target's installPackage); reads { package_id: id } in every state. duplicatePackage copies { package_id, state: 'active', organization_id: null } rows, so the reader is a superset (a source whose only catalog row is a draft is refused though nothing would be copied) — a conservative refusal, text in ③ 4. Right.
    • adopt-orphans: before reassignOrphanedMetadata; reads { organization_id: null } rows kept when package_id is null, empty or 'sys_metadata'. That is reassignOrphanedMetadata's selection verbatim. Right.
    • The control holds by construction at every door: a readTypes that yields no security-catalog type returns null, so a catalog-free package, commit or orphan set stays admitted to the same tenant — pinned for publish-drafts, commit revert, rollback, revert and duplicate on A's own package under both postures (the catalog-free adopt-orphans case is pinned for the operator and under single only; ③ 4 i).
  2. Completeness (brief point b) — one door is left out, and the texts claim it: POST /packages/:id/discard-drafts. Every /packages branch at the head was read: GET /packages, POST /packages, GET /:id, PATCH /:id, DELETE /:id, PATCH /:id/enable, /disable, POST /:id/publish, /publish-drafts, /discard-drafts, GET /:id/commits, POST /:id/commits/:commitId/revert, /rollback, /revert, GET /:id/export, POST /:id/adopt-orphans, /duplicate.

    • Covered by W (on main): the /meta item write doors (save, reset, publish, rollback) on both transports, the permission-set clone door, the data door on sys_position / sys_permission_set / sys_capability, and the Setup environment-door write. By W2: install-local install, upgrade and uninstall, and DELETE /packages/:id. By W3: publish-drafts, revert, rollback, commits/:commitId/revert, duplicate, adopt-orphans.
    • Not catalog doors, by reading: POST /packages (its manifest has no position or permission-set collection; W2 ① 1), PATCH /:id (a name / description / version patch through updatePackage, no item moves), enable / disable (the registry's enabled bit and setPackageDisabled; D6/D12's question, carried since W2), POST /:id/publish (MetadataManager.publishPackage publishes collectPackageMembers from the in-memory registry, which never holds a writable package's stored catalog rows, and requireWritablePackage refuses a code or installed package first), the reads and the export. REST mounts only POST /packages/publish (the marketplace registry publish; rest-route-ledger.ts:457), and no code outside packages.ts calls publishPackageDrafts, discardPackageDrafts, revertCommit, rollbackToPackageCommit, duplicatePackage, reassignOrphanedMetadata or revertStoredPackage. metadata-protocol asks the predicate nowhere, so nothing below a door covers one the door misses.
    • Left out: POST /packages/:id/discard-drafts (head packages.ts:1866), behind requireManageMetadata alone, with no rung and no walled check. It calls protocol.discardPackageDrafts({ packageId }), which lists repo.listDrafts({ packageId }) and discards each draft through discardDraftInItsScope — the SAME population and the SAME per-draft step revertStoredPackage takes for the revert door this PR refuses (protocol.ts:23599–23650; the head's own revert comment at packages.ts:1996 calls them "the rows discard-drafts reads"). So under isolated / group, the tenant W3 refuses at revert ("A discarding the operator's pending catalog draft is refused, and the draft stays") sends discard-drafts instead and discards the operator's pending catalog draft at 200. The refusal W3 adds for the discard effect is bypassable by its sibling verb, and the measurement's placement (a) — "the rows these verbs promote or discard" — is executed for one of the two verbs that discard them. W2's review had named both verbs together ("revert and discard-drafts only discard drafts", 6117680834 ① 1); the W3 measurement measured revert alone and the seat note dispatched revert alone, which is why the dev's execution matches its dispatch and not the rule. 17.7.0 carries discard-drafts too (4e4e881427:1792), so the measurement's six-door reading of the release, and what [Decision] 17.7.0 的两个跨租户写入(W、W2):维持 #15193「不做 17.x 发布线」,只发安全公告加规避说明(A);一次性 17.7.1(B);还是恢复 17.x 维护分支(C) #22938 was told, are short one door.
    • Why this fails the record rather than being escalated: unlike W2's four unmeasured doors, which W2's texts claimed nothing about, this PR's texts state the rule as closed over the discard verbs and over the /packages write doors as a set: the title and the changeset's title line ("the /packages verbs that publish, discard, roll back, revert, duplicate or adopt … are the platform operator's"), census row 55b ("at the /packages write doors (delete, publish-drafts, revert, rollback, commits/:commitId/revert, duplicate, adopt-orphans)"), metadata-api.mdx ("The package's other write verbs follow the same rule under a wall"), permission-sets.mdx ("So do the package verbs that publish, discard, roll back, revert, duplicate or adopt catalog items") and the body ("row 55b names the seven /packages write doors"). Each of those sentences is wrong against the code by exactly this door. Landing them would record, in the census and the docs that release-cut condition (f) and the first next are read against, a refusal that a sibling verb walks around.
    • The remedy is one hunk and its texts: at the discard-drafts branch, after requireManageMetadata and before resolveProtocol, refuseWalledCatalogPackageWrite(deps, _context, 'Discarding the drafts of package "ID"', storedRowTypes(qlService, { package_id: id, state: 'draft' })) — the revert door's reader, since the verbs read the same rows; a refusal pin under isolated and group (the draft stays), the operator and single controls extended by the verb, and the door's own ablation; the changeset gains its FROM → TO bullet, the entry's surface and acceptanceCriteria name it, row 55b lists it, the two docs pages add the verb, and the 17.7.0 note names discard-drafts beside the six.
  3. Envelope (brief point c). All six doors are dispatcher doors and answer deps.error(…, 403), this transport's PERMISSION_DENIED, with the message ACTION is refused: it touches security catalog items. followed by the predicate's own sentence, which names the posture in quotes and ADR-0131 D3 — the pins assert status, code, the quoted posture and the ADR together. That is the split W fixed and W2 kept: FORBIDDEN at the REST doors, PERMISSION_DENIED at the dispatcher. Consistent. The W2 delete's sentence changes from "the package carries security catalog items" to "it touches security catalog items"; W2's delete pin asserts the posture only, so no landed pin moves (text, ③ 4 iv).

  4. Controls (brief point d). single: postureEnforcesWall is false, the predicate admits, readTypes is never called; the single suite has the same sub-operator manage_metadata holder run all six verbs on the operator's catalog-carrying package at 200 — unchanged. The operator: admitted on the PLATFORM_ADMIN rung, pinned on its own catalog-carrying package through every verb under both postures. No tenancy service: classifyService folds a not-registered service to never-registered, resolveServiceOrLoud to undefined, effectiveTenancyPosture(undefined) to undefined, which the predicate admits — inert. A registered tenancy service that cannot answer: classifyService rethrows any other failure, readEffectiveTenancyPosture wraps it in AuthzStoreUnavailableError, the helper's try wraps only readTypes, so the throw reaches handlePackagesRequest's outer catch and errorFromThrown, which keeps the error's own 503. Refused, never admitted, the shape W and W2 landed. The consequence W2's review named for the delete now holds at six more doors: with a failing posture service, every one of them refuses before its items are read, a catalog-free write under single included — the activation gate's shape, not a defect.

  5. Pins and ablations (brief point e). 21 tests: per walled posture, the guard, one refusal pin for each of publish-drafts, revert, rollback, duplicate and adopt-orphans, two for the commit revert (the operator's package URL and A's own), the catalog-free control and the operator control (10 × 2), plus the single control. Each refusal pin compares B's resolution across its own request only, re-establishes the state it needs (the revert pin re-saves the draft before refusing), and asserts the non-movement that matters for its verb (B's grants, the draft row, the empty target package, the orphan's package_id). The ablation table (publish-drafts 2, revert 2, rollback 2, commit revert 4, duplicate 2, adopt-orphans 2; W2's 19 green throughout) is read as reported and not re-measured; the counts match the pin counts above, and the dogfood shards are green on this head. Per 6117751450 the six dispatched doors each carry refusal pins under both postures with the operator and single controls — met; the seventh door of item 2 has none because it has no refusal.

  6. The spec entry and the changeset (brief point f).

    • 18.walled-catalog-package-verbs-retired.ts: the semantic shape (id, surface, replacement, reason, acceptanceCriteria) under protocol major 18 beside W's and W2's entries; the registry is generated, so no index is owed; surface carries no backticks and names the six doors with the condition each is judged by, the "whatever package the URL names" clause included; replacement, reason and acceptanceCriteria read as the code behaves (the operator rung, the catalog-free package, single, the per-verb non-movement). Right for the six it names; it does not name discard-drafts (item 2). One overclaim in acceptanceCriteria: "the same owner runs every verb on a package of their own whose items carry no catalog item with 200" — the file pins that for five verbs, not for adopt-orphans (③ 4 i).
    • The changeset: minor for @objectstack/runtime (the door code) and @objectstack/spec (the entry) — the two published packages the diff moves; @objectstack/qa/dogfood is private and content/docs owes none. Clause-②: yes (narrowing) on its own line; the **BREAKING** banner; the marker registered walled-catalog-package-verbs-retired, an id that resolves and is new in the diff (Check Changeset green). The FROM → TO bullets, each read against its door: publish-drafts and revert "when the package has a pending position or permission-set draft" — the draft reader; rollback "when a commit it would undo touched one" — the reverted set or its equal-instant superset; the commit revert "the commit's own items are judged … the URL's package segment changes nothing" — exact; duplicate "when the source package holds one" — the stored rows; adopt-orphans "when the environment's orphaned rows include one" — exact. The "Unchanged" paragraph (the catalog-free package, the operator, single, no tenancy service, "no extra read happens: the verdict is asked before the items are read") is true of the items; the posture itself is resolved for every caller, a service resolve, the reading W2 accepted. The remedy names the entry, the predicate and the shared helper. Right, except that its title line claims the discard verbs (item 2).
  7. Docs and census (brief point g). metadata-api.mdx: the six-verb list under the delete section, each verb with what it is judged by, reads against the code — right — but its lead sentence "The package's other write verbs follow the same rule under a wall" is wrong by discard-drafts. permission-sets.mdx: "So do the package verbs that publish, discard, roll back, revert, duplicate or adopt catalog items (publish-drafts, revert, …)" — wrong by the same verb; the "refuse any other caller 403 before anything changes" clause is true of the six. system-context.mdx row 55b: the door list adds the six beside the delete and the anchor moves to packages.ts#refuseWalledCatalogPackageWrite, a symbol that exists at the head with the one ec?.isSystem read W2 anchored (moved, none added; Lint & Repo Gates, which carries check:system-context-census, is green); the row's "when the items they act on include a position or a permission set" says two types where the code counts capability too (W2 ③ 4 ii, unchanged). No count line is reintroduced: the diff touches only row 55b, and chore(docs,scripts): system-context page states no census totals; the census gate keeps the register #22950's 5f4e8a544c state of the page stands on main.

  8. No collateral (brief point h). No CHANGELOG.md, no content/docs/releases/, no pnpm-lock.yaml, no docs/adr/**. Right.

② Semver level

  • The diff publishes an accept-set narrowing at six dispatcher doors (BREAKING, shipped minor under ADR-0087's launch-window rule, which check-changeset-no-major.mjs enforces and Check Changeset is green on), and in @objectstack/spec one semantic entry. No export, type, dependency edge or manifest changes. The changeset grades exactly those two packages minor, carries the banner, the registered marker and Clause-②: yes (narrowing); the PR body carries the same line on its third line, after Part of #15204 and a blank line, the placement accepted for W and W2. The level matches what the diff publishes, and so does the Clause-② line. The seventh door of ① 2 changes neither: refusing it is the same narrowing at one more door of the same package, minor under the same rule, inside the same entry.

③ Boundary flags

Every dev flag and every open_questions entry, answered or escalated.

  1. The dev's addendum 6121258157 (head 0e4fdb4e79, open_questions: [], out_of_scope_findings: []). Its one deviation — the branch carries W2's commits and two merges of main beneath the merge — is verified: the net diff against the merge base holds exactly the seven files of the PR's own list and no hunk of W2's, and main's three later commits touch none of those paths. Its summary line "Census row 55b names the seven /packages write doors" is the overclaim of ① 2.
  2. FAIL reason, the one condition of this verdict: POST /packages/:id/discard-drafts (① 2). A live door, in the same file, behind the same gate, discarding the same rows through the same protocol step as the revert door this PR refuses, left admitted to a walled tenant, while the title, the changeset's title line, census row 55b and both docs pages state the rule as closed over the discard verbs and the /packages write doors. Fix it in this PR as ① 2's remedy says, with its pins and ablation, and name it in the 17.7.0 note for [Decision] 17.7.0 的两个跨租户写入(W、W2):维持 #15193「不做 17.x 发布线」,只发安全公告加规避说明(A);一次性 17.7.1(B);还是恢复 17.x 维护分支(C) #22938. The measured doors, the helper, the envelope, the controls, the level and the entry stand as reviewed; a delta review of the added hunk, pins and texts is all the next round owes.
  3. Carried, unchanged: whether a walled organization's manage_metadata may author or publish the environment's non-catalog metadata through these doors (the PR's acceptance notes; 6114823168 and 6117680834 ③ 3). The maintainer's. And the adopt-orphans note in the acceptance notes — any catalog orphan refuses the whole adoption, because the verb rebinds every orphan at once — is the measured placement (e)'s first alternative; the seat may still choose its second ("leave catalog types out of the adoption under a wall"), which is a different accept set and would need its own measurement and texts.
  4. For the seat, text only, none moving the accept set or the level: (i) the catalog-free control for a tenant under a wall does not include adopt-orphans (the harness's ENV_SET orphan would refuse it), so the test title, the file header and the entry's acceptanceCriteria say "every verb" where five are pinned; a sentence, or a catalog-free orphan fixture. (ii) duplicate's reader counts draft and organization-scoped rows that duplicatePackage would not copy; a state: 'active' filter would make it exact, and the body's "the source's stored rows" is true as written. (iii) Row 55b, the changeset and the docs say "a position or a permission set" where the code refuses on any security-catalog type (W2 ③ 4 ii). (iv) The delete's refusal sentence now says "it touches security catalog items" while install-local's W2 sentence keeps "the package carries security catalog items" — two spellings for one refusal across the two transports. (v) The measurement's 17.7.0 reading lists six doors; discard-drafts is the seventh at 4e4e881427:1792.
  5. Local-runs: none. No worktree, build, test, gate or ablation was run; the scratch space pr-23003/ holds only this round's reads (the PR, its diff and files, the card's 176 comments, the check-runs, the ADRs and the git show reads of main, the head and the precedents). The dev's ablation and suite numbers are read as reported; the pins are the gate runs named above.
  6. Size and tier. 7 files, +547 / −42: not governed (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), far under the 3,000-line class. Part of #15204, no closing keyword, assignee set, draft, mergeable true, mergeable_state blocked, two check-runs pending at the reading above. No review is submitted on the PR. ⛔ Not armed, not approved, not readied by this review; the fix of ③ 2, its delta review, the seat's pre-landing check and the queue follow.

Implemented-by: session_015JgoXpZUGtNxnMDvRZsKzP
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: FAIL


Generated by Claude Code

…plicate by the rows it copies

POST /packages/:id/discard-drafts now asks refuseWalledCatalogPackageWrite with
the package's draft rows, like /revert: the two verbs discard the same drafts.
The duplicate door now reads exactly duplicatePackage's selection (active,
organization-less rows).

Pins: discard-drafts is refused under isolated and group (the draft stays); the
operator, single and tenant catalog-free controls run it. The tenant control's
title and the file header now name the verbs it covers (all but adopt-orphans).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…e-door texts

The changeset, the ADR-0087 entry, census row 55b and both docs pages add
discard-drafts (eight /packages write doors with the delete), name capability
beside position and permission set, and say duplicate is judged by the rows it
copies. The entry's acceptanceCriteria says which verbs the tenant catalog-free
control runs.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Delta record on this PR (stage W3 of #15204, the walled catalog refusal at the remaining /packages write doors, ADR-0131 D3), after the full review FAIL 6121612552 on 0e4fdb4e79. That record judged the six measured doors, the helper, the envelope, the controls, the level and the entry right and failed on one condition: POST /packages/:id/discard-drafts left admitted while every text claimed it. This round judges the fix, the texts, the three folded-in text points (③ 4 i–iii of that record), that nothing else moved, and the head's check-runs. Reviewed read-only: the PR body as it reads at render time (updated 12:44Z, after the push), its 7-file list (+576 / −42), the net diff of f10bbe8e8a against its merge base with main (0ede0f8ab3), the PR's own delta git diff 0e4fdb4e79 5ef2293942 (7 files, +54 / −25), the interdiff of the reviewed net diff against the head's, the three new first-parent commits (2bc34da074 code and pins, 5ef2293942 texts, f10bbe8e8a the --no-ff merge of main at 0ede0f8ab3), card #15204's comments through 6123370046 (the addendum 6121258157, the trailer ruling 6123311533 under which the commits were re-made, the next notes 6121771692 and 6123370046), and at the head: packages/runtime/src/domains/packages.ts (the helper, storedRowTypes, the discard-drafts, revert and duplicate branches), protocol.ts's discardPackageDrafts, discardDraftInItsScope, revertStoredPackage and duplicatePackage, sys-metadata-repository.ts's listDrafts and packageScopedRowWhere, SECURITY_CATALOG_TYPES, the pin file, the changeset, the entry, census row 55b, both docs pages, the 17.7.0 tree at 4e4e881427, and .github/workflows/ci.yml as main's merge brought it (#22998). Nothing was built, run, re-run or ablated.

Gate verdicts on the head, read 2026-10-12T13:11Z. 48 check-runs across the head's workflow runs, all completed, none pending, none red: 41 success, 7 skipped. Green: Build Core, Build Docs, Check Changeset (both runs), Check Documentation Links, Dogfood Regression Gate (the bare context, vacuous on this head — ③ 3), Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, Lint · CI, release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates (which carries check:system-context-census), Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Test Core and its six shards, The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter, and, from the 12:41 synchronize run, Auto Label and Check PR Size. The seven skipped: the dogfood shard matrix (listed under its unexpanded name), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL) and Console Pin Gate, which main's #22998 moved off the pull_request and merge_group events (③ 3); Packed-tarball smoke (opt-in), the roster's opt-in; and a second Auto Label and Check PR Size from the 12:44 edited run (the body update), whose jobs exclude edited by their if: — the 12:41 run's green is the one of record for both. Where a judgment below says "pinned", it names one of these runs or the dev's reported local run, and says which; nothing here was run locally. The PR is a draft, mergeable true, mergeable_state clean at this reading; no review is submitted on it.

① Derived judgments

Limited to the delta. Every accept-set or public-surface change the three new commits imply, named right or wrong.

  1. The fix (6121612552 ③ 2, ① 2's remedy): POST /packages/:id/discard-drafts is refused, by the reader its sibling uses. At head packages.ts:1866–1874 the branch runs requireManageMetadata, decodes id, then calls refuseWalledCatalogPackageWrite(deps, _context, 'Discarding the drafts of package "ID"', storedRowTypes(qlService, { package_id: id, state: 'draft' })) and returns its answer before resolveProtocol and discardPackageDrafts — the position the remedy named, and the revert door's reader byte for byte (packages.ts:1995–1998). Against the protocol: discardPackageDrafts discards repo.listDrafts({ packageId }) through discardDraftInItsScope, and listDrafts reads packageScopedRowWhere('draft', { packageId }), which is { state: 'draft', organization_id: null, package_id }. The reader { package_id, state: 'draft' } is that population plus any legacy organization-scoped draft, which the verb skips: the discarded set or a superset of it, the reading 6121612552 accepted for revert. A package with no catalog draft yields no catalog type and stays admitted; the predicate is asked first, so under single, with no tenancy service, for isSystem and for the operator nothing is read (the helper is unchanged). The 403 carries the W3 envelope: PERMISSION_DENIED, "Discarding the drafts of package … is refused: it touches security catalog items." followed by the predicate's sentence naming the posture and ADR-0131 D3. Right. The bypass the FAIL named is closed: the two discard verbs now read the same rows through the same gate, at the same place in their branches.

  2. Its pins. Inside the for (const posture of ['isolated', 'group']) block, one new it ("discard-drafts: A discarding the operator's pending catalog draft is refused, and the draft stays"): the operator re-saves the OP_SET draft (200), A's POST /packages/com.w3.operator/discard-drafts passes refused (status 403, code PERMISSION_DENIED, the quoted posture, ADR-0131 D3), and the OP_SET draft row count stays 1. It re-establishes its own draft, so it is independent of the revert pin before it, as the file's independence rule requires. The controls are extended by the verb: A's catalog-free package (a v4 dashboard draft, discard-drafts 200, zero draft rows left in the package), the operator's catalog-carrying package (a fresh OP_SET draft, discard-drafts 200, zero OP_SET draft rows), and the single holder (the operator drafts, A discards at 200). Count: per walled posture the guard, eight refusal pins and two controls, 11 × 2, plus the single control, 23 — the body's number. The door's ablation row (discard-drafts 2, both postures, everything else green, all seven markers absent from dist/) is read as reported in the body; it is consistent with the file, where only the two new refusal pins assert this door's 403. Right. Where the file runs on this head is ③ 3.

  3. duplicate's reader (③ 4 ii), now { package_id: id, state: 'active', organization_id: null }. duplicatePackage (protocol.ts:24276–24282) selects exactly { package_id: sourcePackageId, state: 'active', organization_id: null } from sys_metadata with the system context and copies each selected row through saveMetaItem({ mode: 'publish', packageId: target }); its loop skips only a row that fails to parse or convert, reported in failed[], so the copied set is the selection or a subset of it, and the reader is the selection itself — exact, not a superset any more. The refinement widens the accept set relative to the reviewed head and is still a narrowing against main: a source whose only catalog rows are drafts or legacy organization-scoped rows is now admitted, and for such a source the verb copies no catalog row, so no catalog item is created under the wall and D3 is not touched by admitting it. Right. Pins: the refusal pin's source OP_PKG carries active, organization-less permission rows (META_AUTHOR, OP_SET) and refuses as before; the catalog-free control copies a dashboard-only package; the body reports the duplicate ablation re-run after the narrowing at 2 red. The organization_id: null predicate is the one the adopt-orphans reader already uses and duplicatePackage itself uses on the same engine, and the adopt-orphans refusal pin was green on the reviewed head's shards, so it reads as "is null" where it matters.

  4. The texts (brief point 2) and the folded-in points i and iii, each sentence read against the code.

    • Changeset: the "Why" bullet now says the measured discard was "through revert, and discard-drafts is the same discard by a second door" — true; the FROM → TO list gains "POST /api/v1/packages/:id/discard-drafts, when the package has such a draft" — the reader; the publish-drafts and rollback bullets say "position, permission-set or capability" — SECURITY_CATALOG_TYPES is position, permission, capability (iii); the duplicate bullet says "the source package's published environment rows (the ones it copies)" — item 3; the remedy paragraph adds "discards". The title line's "publish, discard, roll back, revert, duplicate or adopt" is now true of the code. Right.
    • Entry 18.walled-catalog-package-verbs-retired.ts: surface names /discard-drafts beside /publish-drafts and /revert under "position, permission-set or capability draft" and /duplicate "when the published rows it copies hold one"; still no backtick in surface; acceptanceCriteria lists discard-drafts among the refused verbs and says "every verb but adopt-orphans (which reads the environment's orphans, not the package)" for the tenant's catalog-free control (i) — exactly what the file pins. replacement and reason are unchanged and still true. Right.
    • Census row 55b: the door list reads "delete, publish-drafts, discard-drafts, revert, rollback, commits/:commitId/revert, duplicate, adopt-orphans" — eight doors, each refused through the helper at the head — under "a position, a permission set or a capability" (iii). The anchor and the one isSystem read are as reviewed. Right.
    • metadata-api.mdx: the list under the delete section adds discard-drafts to the draft-judged verbs, duplicate reads "by the source's published items it copies", and the closing sentence names the three types. Its lead "The package's other write verbs follow the same rule under a wall" is now true of every catalog-touching verb; the remaining /packages writes (POST /packages, PATCH /:id, enable / disable, POST /:id/publish) move no catalog item (6121612552 ① 2, unchanged). Right.
    • permission-sets.mdx: the verb list adds discard-drafts. Right.
    • The PR body, read at render time: "the seven package verbs", "a position, a permission set or a capability", the measured table's discard-drafts row marked "not measured; found by contract review 6121612552", 17.7.0 "carries all seven doors (discard-drafts at 4e4e881427:1792)" — verified, that line is the branch — the per-door list with discard-drafts beside publish-drafts and revert and duplicate as "the rows duplicatePackage copies (active, organization-less)", "row 55b names the eight /packages write doors (the delete included) and the three catalog types", 23 tests, "every door but adopt-orphans" for A's control, the ablation table with discard-drafts 2 and duplicate 2 (re-run after the duplicate reader narrowed), and the suites on f10bbe8e8a (59/59 across the three wall files, census OK, runtime 359 files / 5,884 passed, dispatch-gates 110 of 110, trailers clean). Each count matches the diff; the suite and ablation numbers are read as reported. Right. After this round, no text names a door the code does not refuse.
    • The "every verb" sentence (i) is closed by the first of the two routes 6121612552 offered, a sentence: the test title, the file header, the entry and the body each say which verbs the tenant's catalog-free control runs. The changeset's "Unchanged" paragraph speaks of packages, not verbs, and needed nothing.
  5. Nothing else moved (brief point 4). git diff 0e4fdb4e79 f10bbe8e8a restricted to the PR's seven paths is byte-identical to git diff 0e4fdb4e79 5ef2293942 (7 files, +54 / −25); the net diff at the head, git diff 0ede0f8ab3 f10bbe8e8a, holds exactly the seven files, +576 / −42, so the merge commit brought nothing of its own beside main's content; and git diff 48cca4ae00 0ede0f8ab3 touches none of the seven paths, so main's advance rewrote no hunk of the reviewed diff. The interdiff of the reviewed net diff (48cca4ae00..0e4fdb4e79) against the head's (0ede0f8ab3..f10bbe8e8a) consists of the delta's hunks, the index lines of the changed blobs, the new-file line counts (33 → 34, 295 → 315, 45 → 46) and a +7 shift in the hunk headers of packages.ts after the inserted discard-drafts hunk; every other hunk is byte-identical to the one 6121612552 reviewed. The three commits carry the authorized trailer (Co-Authored-By: Claude, with the Claude-Session line; ruling 6123311533, option A), and 0e4fdb4e79 is still their parent, so nothing published was rewritten. Confirmed.

  6. Envelope, controls, helper, the other five doors and the delete, collateral. Untouched by the delta: refuseWalledCatalogPackageWrite, storedRowTypes, readCommitRow, commitItemTypes and the publish-drafts, rollback, commits/:commitId/revert, revert, adopt-orphans and DELETE call sites are byte-identical to the reviewed head. No CHANGELOG.md, content/docs/releases/, pnpm-lock.yaml or docs/adr/** in the net diff. Stand as reviewed.

② Semver level

The delta adds the same narrowing at one more dispatcher door of @objectstack/runtime and makes the duplicate refusal exact over the rows the verb copies; the @objectstack/spec entry is edited, not added; no export, type, dependency edge or manifest moves. The changeset still grades @objectstack/runtime and @objectstack/spec minor with the **BREAKING** banner, the registered walled-catalog-package-verbs-retired marker and Clause-②: yes (narrowing) on its own line, and the PR body carries Clause-②: yes (narrowing) on its third line, after Part of #15204 and a blank line. Check Changeset, which carries check-adr-0087-registration.mjs and check-changeset-no-major.mjs, is green on the head. The level and the Clause-② line are still right with the delta: against main, every door the entry names is an accept-set narrowing, the duplicate refinement included (narrower than the reviewed head, still narrower than main).

③ Boundary flags

  1. The dev's report. No os-dev-report follows 6121258157 on refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204; the fix round is reported in the PR body (updated 12:44Z, read at render) and the three commits. Its deviations of record are the trailer re-make (6123311533) and the merge of main; the addendum's open_questions and out_of_scope_findings were empty, and no later report amends them. The body's "not measured" marking of the discard-drafts row and its 17.7.0 line answer 6121612552 ③ 4 v inside the PR's text; the 17.7.0 note on [Decision] 17.7.0 的两个跨租户写入(W、W2):维持 #15193「不做 17.x 发布线」,只发安全公告加规避说明(A);一次性 17.7.1(B);还是恢复 17.x 维护分支(C) #22938 is the seat's, outside this diff.

  2. Check-runs: no red and no pending run on the head (the gate paragraph: 48 completed, 41 green, 7 skipped). The one run the previous record leaned on for the pins, the dogfood shards, is among the skipped — by main's policy, item 3 — so the body's suite numbers are what stands behind the 23 pins at this head.

  3. The pins have no carrier on this head, by main's policy, not the PR's. main's merge brings skills(ci): two-speed CI — fast set on PR and merge-queue runs, full battery hourly on main and before a release, main-red stops the lane, four required contexts plus the guard, timing gates report-only, test budgets not deletions #22998 ("THE TWO SPEEDS" in ci.yml): Dogfood Regression Gate (k/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL) and Console Pin Gate now run only on push, schedule and workflow_dispatch (their if: ends github.event_name != 'pull_request' && github.event_name != 'merge_group'), and Test Core runs --exclude @objectstack/dogfood. So on this head, and in the merge queue, wall-package-doors-refusal.dogfood.test.ts is run by no check; the bare Dogfood Regression Gate is green with zero expected attestations (its if: always() contract, as the workflow says). On the reviewed head the shards ran and were green with the file's 21 pins; on this head the 23 pins' evidence is the dev's local 59/59 and the ablation table, read as reported. The first CI run of the file is main's post-merge push run and the hourly schedule, where a red is a main-red for this lane. For the seat: accept the reported run, or have the full battery run once on the branch before the queue (a workflow_dispatch of ci.yml). Not a FAIL reason: the diff did not move the policy, the three new assertions ride a file whose other 20 pins were CI-green on the reviewed head, and the new pin's shape is the revert pin's.

  4. Carried, unchanged: the maintainer's question whether a walled manage_metadata may author or publish non-catalog metadata through these doors, and the adopt-orphans whole-adoption refusal against leaving catalog types out of the adoption (6121612552 ③ 3); the two spellings of the refusal sentence across the delete and install-local (③ 4 iv); and the delete sentences of metadata-api.mdx ("holding positions or permission sets") and permission-sets.mdx ("carries positions or permission sets"), which still say two types where the delete reader refuses on capability too — W2's landed sentences, context lines of this diff rather than its hunks.

  5. Local-runs: none. No worktree, build, test, gate or ablation; the scratch space pr-23003-delta/ holds this round's reads only (the PR, its files, diff and body, the card's comments, the check-runs, and the git show / git diff reads of the head, the reviewed head, main and the merge base).

  6. Size and tier. 7 files, +576 / −42: not governed (no docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), far under the 3,000-line class. Part of #15204, no closing keyword, draft, mergeable true, mergeable_state clean at the 13:11Z reading (blocked while the runs were in flight). No review is submitted on the PR. ⛔ Not armed, not approved, not readied by this review; the seat's pre-landing check (the body, the runs named in 2) and the queue follow.

Implemented-by: session_015JgoXpZUGtNxnMDvRZsKzP
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: #23003 (stage W3, the walled catalog refusal at the remaining /packages write doors, ADR-0131 D3) at f10bbe8e8a; queued on green

Epic PM session_01Rerax7QTjKMPCUZxQUtPFR (marchtian), seat epic:#15194, 2026-10-12T13:13Z.

  • Developer: session_015JgoXpZUGtNxnMDvRZsKzP.
  • Measurement: 6117740670. Seat dispatch: 6117751450, which named revert but missed its sibling discard-drafts.
  • Contract review: full review FAIL 6121612552 on 0e4fdb4e79, on that one door only. Delta review PASS 6124989113 on f10bbe8e8a.
  • Trailer re-make: authorized by the maintainer (6123311533). The three new commits carry Co-Authored-By: Claude <noreply@anthropic.com>, and nothing published was rewritten.
  • Next rulings: the first next waits for W3 (6119234024, 6121771692). It is the last item.

What it closes. Under isolated / group, below the operator rung, seven /packages verbs now refuse with 403 PERMISSION_DENIED naming the posture when the items they act on include a position, a permission set or a capability:

  • publish-drafts
  • discard-drafts
  • revert
  • rollback
  • commits/:commitId/revert
  • duplicate
  • adopt-orphans

W2's delete uses the same helper. Together with W (#22895) and W2 (#22935), this closes every cross-tenant or catalog-writing door measured on 17.7.0, which carries seven, as corrected on #22938. That completes release-cut condition (f): W + W2 + W3.

Checklist, read on GitHub, not from the report:

  • PR form: draft, base main; Part of #15204, then Clause-②: yes (narrowing); no closing keyword; assignee marchtian. The body was updated at 12:44Z by the seat through the relay, read against the head, and read back byte-identical. check-governed-merges --pr reads NOT governed. 7 files, +576/−42. No CHANGELOG.md, no content/docs/releases/.
  • The delta since the review: git diff 0e4fdb4e79 5ef2293942 is 7 files, +54/−25: the discard-drafts refusal, duplicate's reader narrowed to the rows it copies, the pins, and the texts. Everything else is the merge of main at 0ede0f8ab3. The review confirmed every other hunk is unchanged.
  • Code, read at the head: eight refuseWalledCatalogPackageWrite call sites, one per verb plus the delete.
    • discard-drafts reads { package_id, state: 'draft' } before resolveProtocol, the revert door's reader.
    • duplicate reads { package_id, state: 'active', organization_id: null }.
  • Changeset and entry: runtime and spec minor, BREAKING, registered walled-catalog-package-verbs-retired. FROM → TO per door, with discard-drafts included.
  • Census and docs: row 55b names eight write doors and three catalog types; metadata-api.mdx and permission-sets.mdx name the verb.
  • Evidence:
    • The developer's run on f10bbe8e8a: the three wall pin files 59/59 (W3 23, W2 19, W 17); runtime 5,884 passed; check:system-context-census; dispatch-gates --commands 110/110.
    • Ablation: one per door, each turning exactly that door's pins red, discard-drafts 2.
  • CI on f10bbe8e8a: 48 check-runs, 41 success, 7 skipped, 0 red, mergeable_state clean. The skips:
  • The pins' CI carrier (review ③ 3). Under skills(ci): two-speed CI — fast set on PR and merge-queue runs, full battery hourly on main and before a release, main-red stops the lane, four required contexts plus the guard, timing gates report-only, test budgets not deletions #22998, wall-package-doors-refusal.dogfood.test.ts runs only on main's push / schedule runs. The seat accepts the developer's local 59/59 for the queue, under that policy. The first main full-battery run after this merge is the CI record of the 23 pins. The seat reads it before the maintainer is told to cut next, and the publish lane's full-battery gate reads it too.

Then: flipped ready and enqueued in this same stroke. On MERGED:

  • a Landed record;
  • the seat reads main's post-merge full battery;
  • the known-limitations notes for next.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 13:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 1802315 Oct 12, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15204-wall-catalog-package-doors branch October 12, 2026 13:42
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants