Skip to content

fix(security)!: under a wall every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) - #23330

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23231-walled-environment-metadata-writes
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23231-walled-environment-metadata-writes

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #23231

Clause-②: no (narrowing)

Execution of ruling A on #23231 (record 6145019463): under a wall, every environment-metadata write admits only the platform operator and isSystem. The one verdict, walledCatalogWriteVerdict, stops special-casing the security catalog, and no door pre-filters on the written type before asking it. single is unchanged.

The Clause-② value is measured: the diff adds no export, no error code and no response field. It removes one (error.details.catalogItems on the install-local refusal). check:api-surface is green.

Step 1: measured first (BEFORE)

Harness. A booted examples/app-crm stack (bootStack, multiTenant: 'posture-only', plus MarketplaceInstallLocalPlugin), with the same shape as W, W2 and W3:

  • Organization A's owner holds an operator-authored set carrying manage_metadata, granted scoped to organization A (rung TENANT_ADMIN, isPlatformAdmin: false).
  • Organization B's owner reads through GET /meta.
  • Under single, A holds the set unscoped (rung MEMBER).

Measured on this branch at bae5841bce, before any code edit. isolated and group answered identically.

Door (A, below the operator) isolated group single B reads afterwards (walled)
PUT /meta/object/:name (create) 200 200 200 A's object
PUT /meta/view/:name (create) 200 200 200 A's view
PUT /meta/flow/:name (create) 200 200 200 A's flow
PUT /meta/view/:name over the operator's view 200 200 200 A's label
POST /meta/view/:name/publish of the operator's draft 200 200 200 the draft, published
POST /meta/view/:name/rollback of the operator's view 200 200 200 the old version
DELETE /meta/view/:name (reset) of the operator's view 200 200 200 404
dispatcher PUT /meta/:type/:name, a non-catalog type admitted admitted admitted (pinned by the then-control in meta-walled-catalog-refusal.test.ts)
POST /packages (create) 201 201 201
PATCH /packages/:id, /enable, /disable, POST /publish 200 200 200
publish-drafts on the operator's package 200 200 200 the pending draft
revert, discard-drafts 200 200 200
rollback, commits/:commitId/revert 200 200 200 the older commit
duplicate, adopt-orphans 200 200 200
DELETE /packages/:id 200 200 200 404
install-local install (a package carrying an object) 200 200 200 A's object
install-local upgrade of the operator's package 200 200 200 A's label
install-local uninstall of the operator's package 200 200 200
POST /automation (flow create; not named by the ruling) 200 200 200 A's flow
POST /meta/_migrate-stored (preview and apply; both transports; added in patch round 1, seat direction 6154226716) 200 200 200 (an install-wide rewrite of the environment's stored metadata)

On the first harness run, adopt-orphans answered 403 under both walls. The environment then held the harness's own catalog orphan, so W3's catalog refusal fired. Once that set was saved into a package, the door answered 200; that second run is the one tabled.

Verdict on the premise. No named door was already closed. One organization's write reached every other organization at every door, so the refusal is owed at all of them. The data door to the ledger itself does not exist: POST /data/sys_metadata answered 405.

The organization_admin assignment reading. On the same harness, organization B's stock owner (organization_admin, rung TENANT_ADMIN) posted POST /data/sys_user_permission_set, under both walls. Every case answered 403 PERMISSION_DENIED (object sys_user_permission_set):

  • the manage_metadata set, to a member of organization B;
  • the same set, to the owner's own user;
  • a set carrying no capability, as the control.

The reason is the grant, not the capability: organization_admin is read-only on sys_user_permission_set and sys_user_position (default-permission-sets.ts). So a stock organization admin cannot hand out manage_metadata. An organization-scoped holder appears only through an operator's grant: an operator-authored set, a system write, or an operator-granted assignment-table grant or adminScope allowlist. This is a reading only, and the door refusal does not depend on it.

The PM's mechanism hypotheses, measured

  1. Two layers of short-circuit: confirmed, plus a third shape.
    • The verdict's own isSecurityCatalogType line (meta-write-capability.ts).
    • The REST walledCatalogRefusal pre-filter.
    • The dispatcher's if (isSecurityCatalogType(type)) wrapper.
    • The /packages helper and install-local both asked the verdict for SECURITY_CATALOG_TYPES[0] and then refused only if the items read included a catalog type.
    • Every pre-filter is gone. The two package helpers no longer read items at all.
  2. feat(metadata-core,rest,runtime,plugin-email,spec)!: the /meta doors carry no organization; organization-admin metadata authoring closes (ADR-0131 D6, #15206 S3) #22447 closes these writes: disproved. It retired the organization-scoped manage_org_presentation arm. A holder of manage_metadata itself passed every door (table above).
  3. The data door is out of reach: confirmed by reading. createWalledCatalogDataDoorRefusal maps only sys_position, sys_permission_set and sys_capability to a type, and calls next() for every other object before asking the verdict. A wider verdict reaches no other data. It and the catalog gates in security-plugin.ts are not edited (seat 2's region).
  4. Ordinary members writing metadata in a product flow: none found.
    • Every door here demands manage_metadata first: metaWriteCapabilityVerdict, requireManageMetadata, install-local's requireInstallCapability and the /automation refuseUngrantedFlowWrite.
    • Its only stock holder is admin_full_access (ADMIN_FULL_ACCESS_CAPABILITIES). organization_admin carries manage_org_users, setup.access, setup.write and manage_mailboxes.
    • Every registered metadata type is environment metadata: the 28 types of DEFAULT_METADATA_TYPE_REGISTRY, from object to skill. Each is stored in sys_metadata, which carries no organization column (ADR-0131 D6/D7).
    • No user-scoped type exists. ADR-0131 D13 parks personal views as a future user-owned object, not as a /meta type.
    • So the refusal removes nothing an ordinary member could do. Under a wall it removes the organization-scoped manage_metadata holder only.
  5. Headquarters IT under group. See the fork-clause reading below.

The fork clause: what the repo says HQ IT holds

  • ADR-0131 D12 item 7: "The metadata-authoring capability (manage_metadata / studio.access) is what headquarters IT holds and plant admins do not." It names the capability, not the posture rung. The same item calls every customization "environment-wide and operator-gated (D6)".
  • The posture ladder (posture-ladder.ts). PLATFORM_ADMIN derives from the platform-admin anchors and never from a held capability. Under group and isolated the anchor is OS_PLATFORM_OWNER_EMAIL, which environment-variables.mdx marks "Required under the walled postures". Holding manage_metadata never lifts the rung.
  • delegated-administration.mdx. It says HQ is not constrained by delegation, and it does not say who holds manage_metadata.
  • Reference deployments. No example runs group. The only group harnesses are the W family dogfood tests, where the platform operator is the seeded admin declared in OS_PLATFORM_OWNER_EMAIL.

Reading. In a stock group deployment, HQ IT's manage_metadata comes from the platform-admin set, so HQ IT is the operator and keeps authoring. The ADR's own text, though, describes HQ IT as a manage_metadata holder without naming the rung. A deployment that gave HQ IT an operator-authored set granted inside the HQ organization has a non-operator HQ IT. Under this PR that HQ IT is refused every metadata write, and its administrators must be declared in OS_PLATFORM_OWNER_EMAIL.

That is the operational consequence the ruling asked to surface. It is reported on #23231, the PR stays a draft, and content/docs/deployment/tenancy-modes.mdx now says it in the deployer's terms.

What changed

  • @objectstack/metadata-core: walledCatalogWriteVerdict.
    • The catalog-only line is gone, so every type is judged alike.
    • The message names the written type ("a position", "a permission set", "a capability", "a package", or "a 'view' metadata item"), the posture, and "ADR-0131 D3, D12 items 7–8, D11". It points at an environment of the organization's own.
    • The export's name is unchanged.
  • @objectstack/rest. walledCatalogRefusal drops its pre-filter. The four /meta doors (save, reset, publish, rollback) refuse a non-operator 403 FORBIDDEN for every type.
  • @objectstack/runtime.
    • The dispatcher PUT /meta drops its catalog wrapper and answers 403 PERMISSION_DENIED.
    • refuseWalledCatalogPackageWrite drops its item reading. All thirteen /packages write doors ask it right after requireManageMetadata, before any read or write. Its three item readers (storedRowTypes, readCommitRow, commitItemTypes) are deleted.
    • The /automation definition writes (create, update, delete, clone) ask the verdict through the new refuseWalledFlowDefinitionWrite. The enablement door keeps the ADR-0126 §5 activation gate.
  • POST /meta/_migrate-stored (patch round 1, seat direction 6154226716):
    • Both transports ask the verdict after their manage_metadata gate and before the protocol is resolved or any row is read: rest-server.ts, and the dispatcher's runtime/src/domains/meta.ts.
    • A non-operator under a wall is refused for preview and apply alike, 403 FORBIDDEN / 403 PERMISSION_DENIED, each in its door's own envelope.
    • The refusal names its subject as "the environment's stored metadata". That is a new key in the verdict's internal subject map, not a new export.
  • @objectstack/cloud-connection. refuseWalledCatalogPackage drops its catalog reading. install-local install, upgrade and uninstall refuse a non-operator 403 FORBIDDEN.
  • Activation remedies (activation-gate.ts). The flow, action and permission-set remedies recommended a clone or a sibling item. That refusal is answered only under a wall, where those writes are the operator's now (and for permission-set clones, since W). The remedies now name the operator.
  • @objectstack/spec.
    • The ADR-0087 semantic entry walled-environment-metadata-writes-retired.
    • The W2 and W3 entries no longer claim that a catalog-free package is "handled exactly as before".
  • Docs.
    • deployment/tenancy-modes.mdx: a postures-table row, and a section on who writes the environment's metadata and on HQ IT.
    • api/metadata-api.mdx: the PUT /meta and /packages rules.
    • permissions/permission-sets.mdx and permissions/capabilities.mdx.
    • Census row 55b in permissions/system-context.mdx, which now anchors refuseWalledFlowDefinitionWrite too.
    • The platform-checklist clause that quoted the action remedy.
  • The ADR anchor for meta-write-capability.ts, and the changeset .changeset/23231-walled-environment-metadata-writes.md.

Re-judged pins (the repo-wide sweep)

A tenant's write under a wall flips from 2xx to 403, so every pin that encoded the 2xx was flipped to assert the refusal and what did not move:

  • meta-walled-catalog-refusal.test.ts: the "a type outside the catalog is not judged" control now asserts a refusal for an object, a view and a flow. It gains isSystem and operator controls.
  • automation-activation-posture-gate.test.ts: the refusal no longer recommends a clone. The clone door refuses the tenant under a wall, with the walled sentence, not the activation one.
  • packages-vetted-org-source.test.ts: the members are refused at every write door before any protocol call. The read doors are unchanged. A new operator arm keeps the "no organization is threaded" property for admitted writes.
  • packages-uninstall-refuse-before-mutate.test.ts: the three non-operator callers are refused with nothing moved. The operator, with and without an active organization, uninstalls naming no organization.
  • packages-orgless-grants-capability-gate.test.ts: the controls show that the capability gate admitted the member, and that the walled refusal answered next. The operator is the positive control.
  • meta-read-org-scope-parity.test.ts: both transports refuse the ex-member under the wall. The operator's save lands env-wide on both.
  • wall-package-refusal.dogfood.test.ts (W2) and wall-package-doors-refusal.dogfood.test.ts (W3): A's catalog-free CONTROL becomes a refusal pin. A can no longer create packages, so the "other package" arms use an operator-created package.

The refusal assertions of the catalog pins are kept verbatim. Message pins assert only what the message names: the posture, the ruling and the subject.

Tests

Patch round 1 (head dbe63f841e = 824228ec35 + a merge of origin/main 2868248e5e + one commit):

  • New pins, one per transport:
    • rest/src/meta-migrate-stored-walled-refusal.test.ts: 6 cases. Under isolated and group, preview and apply are refused FORBIDDEN / 403, and migrateStoredMetadata is never called. The operator, isSystem, single and no-tenancy controls get 200, and the capability gate still answers first.
    • The dispatcher's five same cases in runtime/src/domains/meta-walled-catalog-refusal.test.ts (19 passed).
    • The new subject in meta-write-capability.test.ts (15 passed).
    • A walled refusal pin plus operator and single controls in the dogfood wall file.
  • AFTER for the door: 403 under isolated and group, naming the posture and ADR-0131; 200 under single.
  • Ablation leg 3: with the dispatcher's new call disabled, exactly its 2 refusal pins go red, and the REST transport's 6 stay green. The restore is proven by blob.
  • Re-runs on dbe63f841e:
    • runtime src/domains: 116 files / 2972 passed;
    • rest /meta and posture files: 110 / 2284;
    • dogfood wall files: 99;
    • typecheck: 70/70;
    • narrowed lint: 23 files, 0 / 0;
    • derived gates: 122 run. 121 exit 0, and check:platform-checklist exits 1 on the same 8 ABSENT SYMBOL problems that reproduce on origin/main, none in this diff.

All runs below are at head 824228ec35, the merge of origin/main 5c9bfb0d3f, unless noted.

  • New pins.
    • wall-environment-metadata-refusal.dogfood.test.ts: 21 tests over isolated, group and single. It covers every door above, B's read-back, the operator controls and the single control.
    • packages-walled-write-refusal.test.ts: the 13 doors × 2 postures, with operator, isSystem, single and no-tenancy controls.
    • automation-walled-definition-write.test.ts: the 4 writes × 2 postures, with the same controls.
  • AFTER, on the BEFORE harness.
    • Every walled door above answers 403: FORBIDDEN on REST /meta and install-local, PERMISSION_DENIED on /packages and /automation.
    • B reads the operator's version, unchanged.
    • single is unchanged, cell for cell. The operator control answers 200.
  • Suites.
    • @objectstack/runtime, the whole package: 374 files, 6,178 passed, 19 skipped, in four runs (domains 116 files, plus 55, 100 and 103).
    • @objectstack/rest, the whole package: 284 files, 6,049 passed, 328 skipped, in two runs.
    • @objectstack/metadata-core: 18 files, 552 passed.
    • @objectstack/cloud-connection: 45 files, 693 passed.
    • @objectstack/spec src/migrations: 4 files, 208 passed.
  • Dogfood.
    • The four wall files: 80 passed.
    • The eleven other walled dogfood files that write through these doors: 121 passed. They are automation-toggle-tenant-scope, federated-anchor-provenance, the four install-local-* files, public-form-withdrawal-walled, schedule-acting-organization, security-catalog-showcase, showcase-public-form-redirect and showcase-public-form-walled-intake. This run was before the merge, at 733500eb11.
  • Typecheck. turbo run typecheck for metadata-core, rest, runtime, cloud-connection, spec and dogfood: 71 of 71 tasks green. Runtime's check:test-typecheck covers its test files.
  • Lint. It is narrowed, with all three pieces of evidence:
    • The population: eslint.config.mjs applies to **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}.
    • The count: eslint --no-inline-config --format json over the 22 changed TS files gives 22 files, 0 errors and 0 warnings.
    • The invariance: the config never enables type-aware linting (no parserOptions.project, per its own note), so this diff cannot move a verdict on an untouched file.
  • Ablation, verdict leg.
    • The catalog-only line was put back through ablation-replace.mjs: anchor 1 → 0, blob 1fc932c81c → a6632971b2.
    • metadata-core was rebuilt. ablation-dist-preflight found the marker in dist/index.cjs and dist/index.js.
    • Unit: 5 red, 10 green. Dogfood: all 16 refusal pins red (8 × 2 postures), with the 2 guards, 2 operator controls and the single control green.
    • Restore: blob equals HEAD, git diff HEAD is empty, the rebuild shows the marker absent from all 12 dist files, and the tree is clean.
  • Ablation, REST pre-filter leg.
    • The first attempt was a no-op: the replacement contained the anchor, and the tool refused it (anchor 1 → 1) and restored. Nothing ran.
    • The second attempt put a catalog-only pre-filter in walledCatalogRefusal: anchor 1 → 0, blob 94317088f7 → 1512e81a2c. rest was rebuilt, and the marker was found in dist/index.cjs and dist/index.js.
    • Dogfood: exactly the 10 REST /meta pins red (5 × 2 postures). The /packages, install-local and /automation pins and every control stayed green (11).
    • Restore: blob equals HEAD, the rebuild shows the marker absent, and the tree is clean.
  • Gates. dispatch-gates --commands derives 122 commands; --ran accounts for all 122, 0 not measured.

Acceptance notes

  • Growth beyond the claim's file surface, named.
    • packages/runtime/src/domains/automation.ts: the /automation definition writes. These doors were measured (200, and B reads A's flow), and leaving them open would make this PR's claim false. The bounded in-place fix holds: same defect class, the same one-call shape, no other claim on the file, the same gate family.
    • activation-gate.ts: its remedies were made false by this change.
    • The W2 and W3 migration entries, made false by this change.
    • deployment/tenancy-modes.mdx.
    • The platform-checklist clause.
  • @objectstack/cloud-connection keeps its @objectstack/objectql dependency, which W2 added for the catalog reading this PR removes. No import of it remains. The dependency, the lockfile entry and the vitest alias are left as they are.
  • The verdict keeps the name walledCatalogWriteVerdict, as do walledCatalogRefusal, refuseWalledCatalogPackageWrite and refuseWalledCatalogPackage. The security plugin's data door (seat 2's region) imports the export under that name.
  • The data door is untouched. Since stage 8 retired the catalog row objects, it answers for no registered object.

Generated by Claude Code

…platform operator's (ADR-0131 D12 items 7-8) — wip

The walled verdict stops special-casing the security catalog: the /meta
doors (both transports), every /packages write door, the /automation
definition writes and install-local ask it for every type, with no
catalog pre-filter. The activation remedies that named a clone or a
sibling item under a wall now name the operator.

Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo
Co-authored-by: Claude <noreply@anthropic.com>
…tadata write door; re-judge the pins that encoded tenant writes under a wall

Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo
Co-authored-by: Claude <noreply@anthropic.com>
…— migration entry, changeset, census row 55b, tenancy and API docs; re-judge the W2/W3 catalog-free controls

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

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cloud-connection, @objectstack/metadata-core, @objectstack/rest, @objectstack/runtime, @objectstack/spec, touching 53 documentable anchor(s).

40 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 cc14657ede79cd315fa7647da238a48a6ede9b88.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /api/v1/meta (route, 39 pages)
  • 5 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 — 149 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 cc14657ede79cd315fa7647da238a48a6ede9b88 → packageMentionDocs.

Which tree this was computed on

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

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

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

… is the platform operator's too (ADR-0131 D12 items 7-8)

POST /meta/_migrate-stored asks walledCatalogWriteVerdict on both
transports, after its manage_metadata gate and before any row is read:
403 FORBIDDEN on RestServer, 403 PERMISSION_DENIED on the dispatcher,
preview and apply alike. single, the operator rung and isSystem are
unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Inputs: card #23231 (body and all six comments: ruling 6145019463, claim 6148875683, shared-pages note 6149658350, dev reports 6154188969 and 6155532025, seat direction 6154226716), PR #23330 (body, the 31-file list, the net diff against its merge base), the head's check-runs (41 names; latest run per name: 34 success, 7 skipped, 0 other, Check Changeset included), and the head tree read through git show. Rules read: contract-review.md, the Clause-② lines of execution-duties.md, scripts/pm/clause2-line.mjs, ADR-0131 (D3, D11, D12 items 7–8), ADR-0087 (D3 and the disposition addendum). Nothing was built, run or re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named and judged:

  1. walledCatalogWriteVerdict (@objectstack/metadata-core, a published export): runtime accept set narrows. The first line if (!isSecurityCatalogType(input.type)) return { allowed: true } is gone; under group and isolated a caller that is neither isSystem nor the PLATFORM_ADMIN rung is refused for every type. The TypeScript signature is byte-identical (the same four input fields, the same MetaWriteCapabilityVerdict return), so nothing on the type surface widens. Absent posture and single stay inert. Right — this is ruling A on [Decision] 墙内组织里持有 manage_metadata 的人仍能改共用的对象、视图、流程:只放行平台运营者(A)、禁止组织范围授予(B),还是维持并写明(C) #23231 and ADR-0131 D12 items 7–8 ("plants assign; plants do not define … or any metadata") read through D11 (identical rules, only the wall differs); D3's catalog clause was the first case and is subsumed, not reversed.
  2. isSecurityCatalogType, SECURITY_CATALOG_TYPES, PLATFORM_OPERATOR_POSTURE: kept, semantics unchanged. At the head no non-test caller in packages/ uses the metadata-core predicate as a pre-filter any more (the surviving isSecurityCatalogType hits are objectql's own symbol of that name and core's catalog module). Right — keeping the exports avoids a runtime-export-retired move on symbols the W-family changelogs advertise; the docblock now says the predicate decides no refusal.
  3. REST /meta save, reset, publish, rollback (walledCatalogRefusal): pre-filter removed, 403 FORBIDDEN for every type. Five this.walledCatalogRefusal( call sites at the head: the four item doors plus _migrate-stored. Right — each asks after metaSaveVerdict's capability gate and before resolveProtocol.
  4. Dispatcher PUT /meta/:type/:name: the if (isSecurityCatalogType(type)) wrapper removed, 403 PERMISSION_DENIED for every type. Right — same predicate, the transport's own envelope, pinned with isSystem, operator and single controls.
  5. POST /meta/_migrate-stored, both transports, preview and apply alike. Verdict with type: 'sys_metadata', after the capability gate and before the protocol is resolved or a row is read; the subject key is a private map entry, not an export. Right — seat direction 6154226716 §2 (option A, in this PR) and the ruling's rule, whose door list enumerates and does not bound. Refusing the preview is judged right too: the ruling's rule is per door, the preview is the same operator tool over the same install-wide ledger, and a door-level gate that refuses before parsing the body is the fail-closed shape; the changeset, the semantic entry and api/metadata-api.mdx all declare "preview included", so the claim is as wide as the enforcement and no wider.
  6. All thirteen /packages write doors (refuseWalledCatalogPackageWrite): asked right after requireManageMetadata, before any read or write; 403 PERMISSION_DENIED. Thirteen call sites at the head (create, manifest PATCH, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, commits/:commitId/revert, duplicate, adopt-orphans, delete); the readTypes parameter and the three item readers (storedRowTypes, readCommitRow, commitItemTypes) are deleted, so the W3 500-on-read path is gone with them; rollback and duplicate now refuse before their body check and requireDuplicableSource. The function is module-local, not a runtime export. Right — the ruling names "the /packages write actions" and no catalog pre-filter is left.
  7. install-local install, upgrade, uninstall (refuseWalledCatalogPackage): the manifests parameter and the objectql catalog reading are gone; 403 FORBIDDEN with type: 'package'. The refusal's error.details shrinks from { manifestId, catalogItems } to { manifestId } — a response-shape narrowing on a refusal envelope, declared in the changeset's FROM → TO; at the head catalogItems survives only in shipped CHANGELOG prose, in no spec contract, client type or doc page. The method is a private member of the exported MarketplaceInstallLocalPlugin, so the .d.ts surface does not move. Right.
  8. /automation create, update, delete, clone (new module-local refuseWalledFlowDefinitionWrite, type: 'flow'): 403 PERMISSION_DENIED after the capability gate, before the service; the enablement door is left to the ADR-0126 §5 activation gate. Not named by the ruling, but measured open (200, and B read A's flow) and inside the ruling's rule by the same reading the seat gave _migrate-stored. Right — leaving it open would have made the PR's own claim false.
  9. Activation remedies (FLOW_ACTIVATION_SUBJECT, ACTION_ACTIVATION_SUBJECT, PERMISSION_SET_ACTIVATION_SUBJECT): remedy strings now name the operator, never a clone or a sibling item. activation-gate.ts is not re-exported from the runtime index; these are runtime message strings. Right — that refusal is answered only under a wall, where the clone and author doors now refuse the same caller; a remedy recommending them would advertise a door that refuses the reader (Prime Directive chore: version packages #10). The platform-checklist clause that quoted the action remedy is corrected in the same diff.
  10. Error codes: none new. FORBIDDEN and PERMISSION_DENIED pre-exist in error-code-ledger.zod.ts; the "a new code is always yes" rule does not fire.
  11. @objectstack/spec: one new ADR-0087 semantic entry walled-environment-metadata-writes-retired; the W2 and W3 entries' replacement and acceptanceCriteria corrected. Entries are enumerated from the entries/semantic/ directory (gen:migration-registry, migrations.test.ts), so no hand index is owed. A registry element is data on the published ./migrations subpath, not a type or an accept set, so it widens nothing. The W2/W3 edits remove the sentence "a package with no catalog item is handled exactly as before", which this diff falsifies, and point at the new entry. Right — the ledger stays true to the chain it describes.
  12. ADR anchor packages__metadata-core__src__meta-write-capability.ts.json: invariant rewritten to "every environment-metadata type … no door pre-filters on the written type". Matches the code at the head. Right.
  13. Doors not edited. The security plugin's data door (walled-catalog-data-door.ts) still maps only the three catalog row objects before asking the verdict: that is a filter on which data objects reach it, not a catalog pre-filter on a metadata-type door, it is not a door the ruling names, and the objects it guards retired at stage 8. The permission-set clone door asks the verdict with its fixed type. Not in scope, correctly left alone.
  14. Prose surfaces (content/docs/** five pages, the changeset body, the checklist clause) are outside this review's three surfaces and are ACCEPT's; read for consistency with items 1–9: consistent, including the install-local envelope and the single row.

The PR body's sentence "check:api-surface is green" names the spec-scoped gate in lint.yml (pnpm --filter @objectstack/spec run check:api-surface); it is evidence for @objectstack/spec only. The evidence for the other four packages is items 1, 6, 7 and 9 above: no index file is in the file list and no exported signature moves.

② Semver level

  • Clause-② line. The claim (6148875683), the PR body and the changeset all carry the value no (narrowing). The ruling's workload column wrote yes (narrowing), which in clause2-line.mjs is "a diff that widens one surface and narrows another". This diff widens nothing (① items 1, 2, 7, 10, 11): no new export, no new error code, no new response field, no new authorable key. So no (narrowing) is the measured value and yes (narrowing) would have been a false yes; the ruling's substance — one contract-review-tier review before the queue — is this record. The claim argued the point in writing against the ruling, as it should.
  • Changeset .changeset/23231-walled-environment-metadata-writes.md. Bumps @objectstack/metadata-core, @objectstack/rest, @objectstack/runtime, @objectstack/cloud-connection, @objectstack/spec at minor — the five published packages whose source the diff moves. Correct level: an accept-set narrowing is breaking, and under the launch-window convention (ADR-0087 amendment of 2026-09-13, enforced by check-changeset-no-major.mjs; .changeset/pre.json is present) a breaking change ships minor with the banner and the disposition as the carriers.
  • BREAKING banner. Present: **BREAKING** accept-set narrowing, shipped as minor …, plus the ! in the title; check-adr-0087-registration.mjs reads both and the narrowing arm as declarations of breaking-ness, and the disposition it then demands is present: adr-0087: registered walled-environment-metadata-writes-retired, an id that resolves at the head and is new in this diff.
  • ADR-0087 semantic entries match what the diff publishes. The new entry's surface names exactly the doors of ① items 3–8 (REST /meta four verbs, dispatcher PUT, _migrate-stored on both transports preview included, the thirteen /packages verbs, install-local POST and DELETE, the four /automation definition writes); its replacement names the operator rung and the custom-door rule; its acceptanceCriteria are the dogfood pins' own readings. The W2/W3 entries no longer promise the catalog-free path this diff closes. The FROM → TO list in the changeset matches the entry door for door, and names the catalogItems removal.
  • Check Changeset, Lint · CI, release & engine gates and Lint & Repo Gates are green on this head, which is where the two gates above run.

③ Boundary flags

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

  1. Open question (6154188969): wall _migrate-stored too? Answered by seat direction 6154226716 §2 (A, in this PR), executed in patch round 1 and verified in the diff (① item 5). Closed. The second report carries open_questions: [].
  2. File surface grew beyond the claim (automation.ts, activation-gate.ts, the W2/W3 entries, tenancy-modes.mdx, the checklist clause). Answered: each is a measured reach or a sentence this diff falsified; same defect class, same gate family, no new rule. Accepted (① items 8, 9, 11, 14).
  3. Preview of _migrate-stored refused too (6155532025 deviation 1). Answered right in ① item 5; declared in the changeset, the entry and the docs.
  4. Changeset's "Unchanged" clause on _migrate-stored deleted; census row 55b and the ADR anchor name the door (deviations 2–3). Answered: all three registers now say what the code does; check:system-context-census and check:adr-anchors are inside the green lint runs.
  5. PR body edits the seat owed after round 1. Verified present on the PR: the _migrate-stored row in the BEFORE table, its entry under "What changed", the dropped "Not walled here" note, the round-1 pins and ablation leg 3 under "Tests".
  6. Fork clause (HQ IT). Reported on the card as the ruling required (6154188969; restated in 6154226716 §1), written into deployment/tenancy-modes.mdx: a stock group deployment's HQ IT is the operator and keeps authoring; an HQ IT holding only an organization-scoped set must be declared in OS_PLATFORM_OWNER_EMAIL. Per the ruling, "not a reason to keep the hole" — no block from this record; the seat's round report carries it to the maintainer.
  7. Ablation no-op redone; four commands backgrounded; the gate battery straddling two commits; the step-1 harness never committed (6154188969 deviations 2–4, 6; 6155532025 deviation 4). Procedural, with no effect on the diff; the committed dogfood file carries the BEFORE/AFTER pins, and the head's check-runs are the gate verdicts this record reads.
  8. Commit trailers model-free (deviation 5). Consistent with AGENTS.md; no model identifier is in the diff, the PR body or this record.
  9. check:platform-checklist exits 1 on origin/main with 8 ABSENT SYMBOL problems, none in this diff — "noted, not filed". Escalated to the dispatching seat: lint.yml keeps that gate out of per-PR CI by maintainer decision (it is a hand-run gate), so it is not a verdict on this head and not a FAIL here; but a red on main that reproduces is a reproducible defect under Prime Directive chore: version packages #10, and seat direction 6154226716 §3 already owns the ACCEPT reading of it — the seat files the card if the reading holds.
  10. @objectstack/cloud-connection keeps the @objectstack/objectql dependency W2 added, now unimported. Not a contract matter; carried in the PR's Acceptance notes, which is where Prime Directive chore: version packages #10 puts it. Answered.
  11. The names keep "catalog" although they judge every type. Answered: a rename would retire exports that seat 2's walled-catalog-data-door.ts reads under those names (a runtime-export-retired move on another lane's region); the docblocks say why the name stays. A later card, when seat 2's lane allows.
  12. walled-catalog-data-door.ts vestigial after stage 8. Seat 2's region (C3 #15204 stages 7a ∥ 7b → 8-post, build ahead: delete the permission-set projection and the position write-through that stage 8 leaves unreferenced, then the post-stage-8 catalog gates and residue #22975), not this PR's; carried as a note to that lane. Not escalated further here.
  13. Shared-pages note (6149658350, domain:devx). This PR edits permission-sets.mdx and system-context.mdx, two of the four pages C3 #15204 docs sweep: ten content/docs pages still describe the four retired catalog row objects; draft now, land after stage 8 #22976 will touch; C3 #15204 docs sweep: ten content/docs pages still describe the four retired catalog row objects; draft now, land after stage 8 #22976 is not dispatched. The note's own rule governs: whichever lands later merges main and re-reads the other's sentences. Nothing owed before landing.
  14. Docs Drift Check (PR comment 6154005622). Advisory and precision-first; the five content pages this diff edits are the ones its anchors reach through the walled doors; the rest is ACCEPT's prose reading.

Implemented-by: claude/issue-23231-walled-environment-metadata-writes
Reviewed-by: session_01P88ehrZ6hM19hBzdpsQTTo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 21:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 13, 2026 21:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 430e5f5 Oct 13, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23231-walled-environment-metadata-writes branch October 13, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants