Skip to content

feat(runtime): activation door for positions and permission sets on sys_metadata_activation (stage 2c) - #22736

Merged
objectstack-fleet[bot] merged 51 commits into
mainfrom
claude/issue-15204-s2c-catalog-activation-door
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 51 commits into
mainfrom
claude/issue-15204-s2c-catalog-activation-door

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #15204
Clause-②: yes

This is stage 2c only: the activation door for permission and position. Stages 2a, 2b and stage 1 have landed; the rest of the card stays open, and #15204 remains open after this merges. The PR was opened stacked on stage 1 and was retargeted to main when stage 1 merged.

Size: 1,352 changed lines against the merge base 149294c02c (+1,279 / −73, 17 files), under the 3,000 limit. main was merged in with no rebase (stage 1, then 2b and stage 3).

The route (for objectui C9)

POST /api/v1/security/_activation/:type/:name, with :type either position or permission. The body is { enabled?: boolean } and enabled defaults to true. With project scoping on, the route is also mounted under /api/v1/environments/:environmentId/security/_activation/:type/:name.

  • Success: 200, data = { type, name, enabled }.
  • Refusals: 401 for anonymous callers (the /security domain floor). 403 PERMISSION_DENIED without manage_metadata, or for a tenant admin under a group or isolated posture. 400 for a wrong path shape or a type other than the two. 400 VALIDATION_FAILED for a bad body. 404 for a name that does not resolve in the security catalog. 503 SERVICE_UNAVAILABLE when no catalog is bound or the catalog cannot be read. 501 when the composition carries no activation ledger. 403 PERMISSION_DENIED from the last-admin guard when the request switches admin_full_access off.

Setup's Deactivate on the position and permission-set pages should call this route. That wiring is C9's follow-up, not part of this PR.

What changed

Stage 1 makes resolveUserAuthzGrants read deactivation for position and permission from sys_metadata_activation, and never from the catalog row's active. Measured on stage 1's head 83b54d84: no door wrote that ledger for these two types. Only the flow store (toggleFlow) and the action store (setActionActive) did, and sys_metadata_activation declares apiMethods: ['get', 'list'], so the data API cannot write it. Setup's Deactivate therefore switched nothing off. This PR adds the write half. The release-cut condition (a) on the card depends on it.

  • packages/runtime/src/domains/catalog-activation.ts (new): the door. The steps run in this order:
    1. Both authority gates run first: refuseUngrantedActivationAuthoring (manage_metadata), then refuseUngrantedActivationWrite (the ADR-0126 §5 posture rule). They are the same functions the action door calls, with two new ActivationSubjects.
    2. The path shape is checked.
    3. The body is read with the shared reader.
    4. The name is resolved through securityCatalogReaderOf(engine), the reader stage 1 binds and the resolver reads, so the door and the resolver agree on which names exist.
    5. One row is written through ObjectStoreMetadataActivationStore(engine, type), the row contract the flow and action stores already share, carrying the definition's _packageId.
  • The door writes no definition and no catalog row active, and authors no clone. The write goes through the engine, so the ledger's hooks run. Stage 1's refuseLedgerSwitchingAdminOff refuses switching admin_full_access off (stage 2b replaces that hook), and the grants cache drops what it holds for the ledger.
  • domains/activation-gate.ts: adds POSITION_ACTIVATION_SUBJECT and PERMISSION_SET_ACTIVATION_SUBJECT.
    • The permission-set remedy names the clone, which regime C charters for permission sets.
    • The position remedy names the operator, plus removing assignments in the caller's own organization, because a position has no clone door.
    • readActivationBody moves here from the action door unchanged, so both _activation doors read one body contract. The action door's messages and envelope are byte-identical, and its suites pass unchanged.
  • domains/actions.ts: calls readActivationBody instead of its inline copy.
  • domains/security.ts: the _activation arm sits after the anonymous floor and before the security service probe. The door does not use that service.
  • dispatcher-plugin.ts: registerSecurityActivationRoutes mounts the one route at the same call sites as the action routes. The rest of /security is the REST server's on the wire. The _activation literal collides with none of its routes, because machine names cannot begin with _.
  • route-ledger.ts: one row with disposition server-only, for the action door's reason: its caller is the Setup console.
  • Gate bookkeeping for the new route:
    • scripts/check-route-envelope.mjs declares catalog-activation.ts in DISPATCHER_DOMAINS as { handBuilt: 0 }, because every answer goes through deps.*.
    • The route-ledger census sentence now reads 85 (--fix).
    • The authz matrix docblock's ledger figure now reads 85 rows.
    • scripts/engine-double-contract.pinned.json records the two new engine doubles. Their update routes through assertEngineUpdateDispatch.
  • .changeset/15204-s2c-catalog-activation-door.md: @objectstack/runtime minor, Clause-②: yes, with the route named.

Where the producer lives. It is packages/runtime/src/domains/, as the dispatch expected. The gate, the two sibling doors and the dispatcher mount are all there. The /security REST surface in @objectstack/rest was not used. Putting the door there would have split the activation authority across two packages.

Who may call it: the same authority as the flow and action doors (#22621 → A). Nothing is widened.

Tests

  • domains/catalog-activation-door.test.ts (37 cases):
    • Authority, per type: single, no tenancy service, no manage_metadata, anonymous, and group/isolated for the tenant admin and for the operator. Every refusal asserts that nothing was written.
    • Contract: four wrong shapes, a one-letter-off body key, a string enabled, the empty-body default, unknown names, a name of the other type, no catalog bound, no ledger object, the package carried onto the row, and re-enable updating the single row.
    • A hook refusal keeps its own 403 and code; a store failure is a 503.
    • Resolver effect: resolveUserAuthzGrants over the same engine stops granting through a switched-off position (the holder's direct set is untouched) and grants again when it is switched back on. A switched-off set stops granting both directly and through a position. Each leg has a control.
  • domains/tenancy-posture-outage-gates.test.ts: the third activation door joins the shared outage pin: a throwing tenancy gives 503 with nothing written; never registered admits the org admin; scoped and healthy refuses the tenant admin 403 and admits the operator 200.
  • qa/dogfood/test/catalog-activation-door.dogfood.test.ts (6 cases, booted showcase):
    • A declared position and a declared set each switch off and on, leaving one row each.
    • The resolver over the booted engine stops granting exec → showcase_executive for a signed-up holder, then grants it again. A control precedes the flip.
    • The real last-admin hook refuses admin_full_access off with 403 PERMISSION_DENIED, and no row lands.
    • An unknown name gets 404; a capability type gets the door's own 400, which proves the route reached the door rather than missing at the router.
  • Ablation: with the write's active: enabled changed to active: true (via scripts/ablation-replace.mjs), 4 of 37 cases go red: both "flips it" row cases and both resolver-effect cases. Restored byte-identical: blob c5752231 equals HEAD, and git diff HEAD is empty.

Local verification

Exit codes were captured before any pipe. Heavy runs went through os-verify-lock.

  • @objectstack/runtime: typecheck exit 0, which includes check:test-typecheck for the test files. test: 349 files, 4,975 passed, 19 skipped.
  • @objectstack/client (reads ROUTE_LEDGER as source): 51 files, 653 passed.
  • @objectstack/dogfood: typecheck exit 0. The new file passed 6 of 6, route-ledger-live-mount-parity 8 of 8, authz-conformance 55 of 55, and showcase-anonymous-deny-surfaces + permission-set-discard-overlay-eligibility + explain-write-door-parity 106 of 106.
  • Other ROUTE_LEDGER readers: metadata-protocol protocol.destructive-409-face-inventory 26/26, plugin-sharing share-link-envelope.conformance 26/26, rest direct-mount-introspection 13/13.
  • Spec after the stage 1 merge: check:generated reports all 15 artifacts up to date.
  • Gates: dispatch-gates --commands derives 147 at 1b58797a. --ran reports 147 derived, 147 run, 0 NOT-MEASURED, 0 UNRUN, with every exit code recorded. 146 exit 0. One exits 1: check:platform-checklist, with seven ABSENT SYMBOL findings in metadata-protocol/src/protocol.ts and service-storage/src/attachment-access-hooks.ts. That is the same red stage 1 recorded as identical on main; neither file is in this diff.
  • Not run locally: repo-wide pnpm lint, which CI runs.

Acceptance notes

  • metadata-protocol's PACKAGED_BASE_REGIME.permission still names only the clone in the packaged-base refusal sentence. It could also name this switch. The sentence stays true as it is. Left for a carrier that touches that file.
  • GRANTS_CACHE_WATCHED_OBJECTS already watches the ledger (stage 1), so this door's writes invalidate cached grants with no change here.
  • capability has no switch: it is code-only on the regime, and the door refuses it with 400.
  • No JS SDK method. Adding one later reclassifies the ledger row to sdk.

Generated by Claude Code

Landing update by the epic seat (session_01Rerax7QTjKMPCUZxQUtPFR): first lines and size updated to the landing head a27c4db8. Since the body was written, the door also refuses the audience anchors position/everyone and position/guest (400, no write), and the admin_full_access dogfood case pins the ruled pair after 2b. Contract record 6105494730 (PASS).

claude added 30 commits October 10, 2026 08:31
…d bodies from the catalog

Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Co-authored-by: Claude <noreply@anthropic.com>
…declared at kernel:ready

Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Co-authored-by: Claude <noreply@anthropic.com>
…ledger regenerated

Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Co-authored-by: Claude <noreply@anthropic.com>
…ssionSets through the write-through

Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
Co-authored-by: Claude <noreply@anthropic.com>
…activation ledger

The resolver honours ADR-0049 deactivation through sys_metadata_activation
(types position and permission) and no longer reads the catalog row's active
column. convertDeactivatedCatalogRows turns existing active=false rows into
ledger rows for the upgrade ceremony; it is not wired into boot. A composition
whose registry has no ledger object issues no ledger read.

The admin-standing surface now derives from the ledger; the last-admin guard
refuses a ledger write that switches admin_full_access off. The name-fold
warning names permissionSets as the governed remedy.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
The resolver reads deactivation from sys_metadata_activation; the install
fixtures' grant maps answer it empty, as they answer the other authz tables.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…-position-permission-sets

# Conflicts:
#	packages/plugins/plugin-security/src/security-plugin.ts
The merged capability declarations are told apart by _packageId; the test
registry now stamps it as the engine registry does.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…Sets; the ledger-guard test double holds the caller's bound

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
… zh-CN, ja-JP and es-ES

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

Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN
Co-authored-by: Claude <noreply@anthropic.com>
… gate; engine-double ledger learns both doubles

Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN
Co-authored-by: Claude <noreply@anthropic.com>
…ermission-sets' into claude/issue-15204-s2c-catalog-activation-door
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat note: Test Core (3/6) red on 306de5fe, held for the post-stage-1 merge round

Epic PM session_01Rerax7QTjKMPCUZxQUtPFR (marchtian), seat epic:#15194, 2026-10-11T01:45Z.

The failing check. Job 114341427182 (run 38095818706) fails, along with the Test Core roll-up. The failing test is packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts, case "a host config — instances in plugins beside its own metadata, the showcase's shape", assertion :136. The plan returned a pending create_table for sys_activity, plugin-audit's object, after a boot with OS_MIGRATE_AND_EXIT=1.

Why the seat does not read it as this PR's:

What happens next. No fix exists to port. The seat's relay cannot re-run jobs. So the next CI run comes from the post-stage-1 merge round: once #22723 merges, this PR retargets to main and the dev merges origin/main under landing-operations §A. If Test Core (3/6) reds again on that head with the same sys_activity row, the seat treats it as real and has it root-caused. If the cause lies outside this diff, it is raised as a proposed patch, not added to this PR.


Generated by Claude Code

Base automatically changed from claude/issue-15204-s1-position-permission-sets to main October 11, 2026 01:48
…c-catalog-activation-door

# Conflicts:
#	packages/spec/src/migrations/registry.ts
…tch is described

Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN
Co-authored-by: Claude <noreply@anthropic.com>
…'s active flag decides no grant, and names who still reads it

Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN
Co-authored-by: Claude <noreply@anthropic.com>
…atalog's activation switch

Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN
Co-authored-by: Claude <noreply@anthropic.com>
… pair; row-active.ts names its remaining readers

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2c2b3f4989a4b406df8286ffcd3ed891735a0bfe
Local-runs: none

Stage 2c of #15204 (amendment 6101897723; seat answers 6103293232; pin rule 6103398921; stage 1 ACCEPT 6102862135 and Landed 6104351995). Read on GitHub only: the card body and all 56 comments, the PR body, its 17-file list, the net diff against main bfc15d27 (+1,174 / −73), the dev's report 6102842837 and merge-round addendum 6104607329, the seat note 6104322014, and the check-runs on this head. main was read for the gate bodies, the activation store, the catalog reader, the ledger object, the resolver's ledger read, the REST /security rows and the two docs pages.

① Derived judgments

  1. The route's accept set — right, and the action door's exactly. POST /api/v1/security/_activation/:type/:name (also under /environments/:environmentId when scoping is on) admits a caller iff: not anonymous (the /security domain floor answers 401 before the arm); executionContext.isSystem or systemPermissions holds manage_metadata (refuseUngrantedActivationAuthoring, else 403 PERMISSION_DENIED); and either no tenancy service, a posture that enforces no wall, or posture === 'PLATFORM_ADMIN' (refuseUngrantedActivationWrite; a wall with a non-operator is 403, a tenancy that throws is the 503 outage). Both are the unchanged functions on main, with two new ActivationSubjects. Pinned per type in catalog-activation-door.test.ts (single · no tenancy · no capability · anonymous · group/isolated for the tenant admin and the operator) and in the shared outage pin (4 cases).
  2. No widening of who may write a position or permission set ([Decision] 切换后谁能在 Setup 里新建/编辑岗位和权限集:保持今天的权限(持 manage_metadata 者),还是放开给组织管理员(安全边界) #22621 → A) — right. The door writes no definition and no catalog row: ObjectStoreMetadataActivationStore.setActive touches sys_metadata_activation alone (find by (metadata_type, name), update by id or insert). Its accept set is the metadata door's catalog-write authority (a system write, or manage_metadata), narrowed under a wall; an organization administrator without manage_metadata is refused in every posture. sys_metadata_activation keeps apiMethods: ['get', 'list'], so the data API still cannot write it: the ledger's writers are the three activation doors and system writes.
  3. Which (metadata_type, name) may be switched — right. :type is closed to position | permission, the two LEDGER_CATALOG_TYPES the resolver reads (capability is the door's own 400). The name must resolve through securityCatalogReaderOf(ql), the reader stage 1 binds and resolveUserAuthzGrants reads: registry first (platform sets, package manifests, the six built-in positions), then the metadata service; a definition owned by a disabled package resolves to nothing. Unknown → 404, no reader or a reader that throws → 503 SERVICE_UNAVAILABLE, no ledger object → 501. The row carries the definition's _packageId. Door and resolver therefore share one name space, which is what makes the resolver-effect pins (unit and booted showcase) evidence.
  4. What is refused — right, with one escalation. (a) Both authority gates run before shape, body and lookup, so a refused caller learns neither the contract nor whether the name exists (pinned). (b) The last-admin guard: stage 1's refuseLedgerSwitchingAdminOff on main refuses active: false for (permission, admin_full_access) on insert and on update (re-reading the row by id when type and name are absent); the door surfaces the hook's own 403 PERMISSION_DENIED and writes nothing, pinned on the real boot. That hook alone guards lockout on this head; its ruled replacement is 2b's (③.6). (c) Anchor names everyone and guest are not refused. Both are registered position items (builtin-positions.ts registers the six built-ins through registerItem('position', …)), so they resolve, and any manage_metadata holder under single may write (position, everyone, active: false); the resolver honours it (stage 1's pin off('position', 'everyone') → permissions: [], everyone absent). That is a one-write, deployment-wide removal of the authenticated baseline. It adds no privilege, it is reversible by the same caller (whose standing rides admin_full_access, which the hook protects), and no ruling I was given forbids it: ADR-0090 D5/D9 is about privilege bits on the anchors, [Decision] 切换后谁能在 Setup 里新建/编辑岗位和权限集:保持今天的权限(持 manage_metadata 者),还是放开给组织管理员(安全边界) #22621 → A about definition writes, and the regime's switch is documented as an installation-wide incident-response control. Escalated in ③.7, not a FAIL reason.
  5. The anchor-guard carrier of 6102862135 — this door is not such a path. The carrier binds "any later stage that opens an environment write path for an anchor definition". This door writes one sys_metadata_activation row and no definition; describeAnchorForbiddenBits(def, anchor) judges the bits a definition carries, and an activation row changes none. The anchors' definitions stay sealed exactly as stage 1 measured (refusePackagedBaseOverride, allowOrgOverride: false, NAMESPACE_CONFLICT). No definition-write gate is owed here, and none is built.
  6. The public surface, enumerated — right. One new HTTP route, server-only, route-ledger row 85 (census regenerated; the authz matrix docblock and the blind-spot census moved 84 → 85 with their controls, reachable with population, blindSpot 0). activation-gate.ts gains readActivationBody, POSITION_ACTIVATION_SUBJECT and PERMISSION_SET_ACTIVATION_SUBJECT; catalog-activation.ts is new. actions.ts now reads its body through the shared reader: the three refusal strings and the VALIDATION_FAILED envelope are byte-identical to the inline copy they replace, so the action door's contract does not move. The security.ts arm sits after the anonymous floor and before the security service probe, so the door serves on a composition without that service while every other /security path keeps today's answer (pinned: /suggested-bindings still 503 "Security service not available"). The dispatcher mounts the route at the three action-door call sites; the literal _activation collides with no REST /security row (explain, my-delegable-scope, suggested-bindings/…, permission-sets/:id/discard-overlay; none takes a parameter in that segment), and the booted case that sends capability/manage_metadata and gets the door's own 400 proves the wire reaches it.
  7. The amendment's exclusions hold. No transition piece: the door reads the catalog reader and writes the ledger, with no row fallback, no junction read and no write-through. No retired module is touched. Nothing under content/docs/releases/. The lane is runtime/src/domains/ plus the mount, the ledger, two scripts, the dogfood tests and three docs/comment files; nothing in plugin-security/src, plugin-auth/src or plugin-sharing/src, so no overlap with 2a or 2b.
  8. The merge round is a merge round. The 14 stage-2c files are byte-identical, hunk for hunk, to 306de5fe (compared file by file). The net diff against main bfc15d27 holds those 14 plus authorization.mdx, metadata-lifecycle.mdx and row-active.ts; no stage 1 file appears in it, and the one conflict (packages/spec/src/migrations/registry.ts) resolved to main's deletion, so it is absent. The new file's blob c5752231b1e is the blob the dev's ablation reports restoring to.
  9. The store writes with the system context, so row-level security does not judge the ledger write; the two gates are the whole authority, and the engine's hooks still run (the booted pin shows the last-admin hook firing). The same shape as the flow and action stores.

② Semver level

  • .changeset/15204-s2c-catalog-activation-door.md: @objectstack/runtime minor, Clause-②: yes on its own line, naming the route, body, effect, authority and refusals. Right: one additive HTTP route on the dispatcher, no existing route's contract changed. Not BREAKING.
  • No other package publishes a change: @objectstack/core is only imported (its exports landed with stage 1); @objectstack/client gains no method (server-only row); @objectstack/dogfood is private; scripts/ and the docs are repo-owned; row-active.ts is a comment. No further changeset is owed.
  • Clause-②: yes matches the diff (a new write door widens the public surface's accept set) and the PR body's second line. Amendment 6101897723 asked for yes on 2c.
  • ADR-0087 marker: none owed (no packages/spec disposition in the diff) and none present.

③ Boundary flags

  1. Q1 route spelling → A (6103293232). The route as built is judged in ①.1–①.6.
  2. Q2 the stacked PR → A for feat(runtime): activation door for positions and permission sets on sys_metadata_activation (stage 2c) #22736 (6103293232). On this head the base is main and the merge round is one no-rebase merge (638ea68d). The PR body's first line still reads "Stacked on stage 1 … the base is claude/issue-15204-s1-…" and its size line names the old base: stale prose, not a published page; the follow-up push should rewrite both.
  3. Test Core (3/6) · plan.boot-parity · sys_activity (seat note 6104322014). Green on this head: Test Core (3/6) and the Test Core roll-up both conclude success on 2c2b3f49, after the one red on 306de5fe. The seat note's reading (not this diff's: no object registration, boot composition or migrate code is touched, and the 14 stage-2c files are unchanged between the two heads) stands; the question of a flake in plan.boot-parity.integration.test.ts on a tree without this PR is main's to measure, not this PR's.
  4. Docs owed this round (Landed 6104351995). authorization.mdx now names the route, the body, who may call it and the 404; every sentence is true of the code. metadata-lifecycle.mdx: the switch sits in a sentence after the Regime C table, not in the permission row, and says the refusal does not name it yet. That is the dev's deviation, and it is the true shape: the table's column is "Sanctioned path the refusal names", and PACKAGED_BASE_REGIME.permission names only the clone (the out-of-scope finding the seat accepted in 6103293232). Answered by the seat as C: the permission regime row gets no switchOff (the composed refusal sentence would pass the pinned < 500-character bound of protocol.packaged-base-refusal.test.ts), so the permission row keeps listing only what the refusal names and the switch stays in the sentence after the table, which is what this head publishes. The follow-up push on this PR adds switchOff to PACKAGED_BASE_REGIME.position only (POST /api/v1/security/_activation/position/:name, operator-only under a wall), with its changeset and re-pins; that delta is judged on its own head. One nit for that push: under C the word "yet" in "The refusal does not name it yet" promises a change that is not coming for permission. Not a FAIL reason on this head.
  5. The comment-only row-active.ts correction (2a's finding, carrier 2b per 6103991518, moved to this round by 6104535364): delivered. Checked on main: resolve-authz-context.ts has no isRowActive reference, and each named reader has one (plugin-auth last-admin-guard.ts, plugin-security explain-engine.ts and security-plugin.ts, plugin-sharing sharing-rule-service.ts). No code or export changes; no changeset owed. The list is dated "on main": after 2b lands, the guard's enumeration and readPositionActive leave it, so the follow-up push, which merges main again, re-measures that list.
  6. 2b × 2c pin rule (6103398921). 2b (plugin-auth, plugin-sharing: the last-admin guard, self-registration, default-organization and sharing-rule readers read the security catalog and the activation ledger (#15204 stage 2b) #22745) is ACCEPTed (6104535364) and queued, not merged at this head, so catalog-activation-door.dogfood.test.ts still pins stage 1's flat 403, which is true of main today. 2c lands second, so the follow-up push rewrites the admin_full_access case to the ruled pair (403 when no administrator who can sign in remains; a 200 control restored in finally). Landing condition: this head must not merge after plugin-auth, plugin-sharing: the last-admin guard, self-registration, default-organization and sharing-rule readers read the security catalog and the activation ledger (#15204 stage 2b) #22745 does, because on that tree the booted showcase's seeded admin also owns the Default Organization and the case answers 200.
  7. Escalated: the audience anchors are switchable (①.4c). The seat names a carrier: either the door's step 4 refuses position/everyone and position/guest, or a ruling records that the installation-wide switch covers the anchors. Non-blocking for this head.
  8. PACKAGED_BASE_REGIME.permission names only the clone (dev's out-of-scope finding): accepted with no carrier in 6103293232, and under C it stays so; the position regime row's switchOff is the follow-up push's item (③.4).
  9. check:platform-checklist exits 1 locally with the same ABSENT SYMBOL anchors stage 1, 2a and 2b measured identical on main; neither file is in this diff.
  10. Check-runs on this head, all complete: 35 runs, 33 success, 2 roster skips (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure. Green: all six Test Core shards and the roll-up, all three Dogfood Regression Gate shards and the roll-up (the shard 3/3 that was red on 1b58797a), Dogfood Verify CLI, Lint & Repo Gates, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, the four Type Check gates and TypeScript Type Check, Spec property liveness, Temporal Conformance, Governed Surface Queue Guard, Flag docs affected by code changes, and the five PR-form gates. No red is left to attribute.

Implemented-by: session_015LutkhFn8wzgD5J86PtpYN
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

…dience anchors; their sealed refusal names no switch

Claude-Session: https://claude.ai/code/session_015LutkhFn8wzgD5J86PtpYN
Co-authored-by: Claude <noreply@anthropic.com>
…'s refusal keeps the managed seal's edit remedy; the switch is documented beside the table

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Supersedes 6104861792 (PASS on 2c2b3f49). Stage 2c of #15204, the landing head: the follow-up push the Landed records 6104692060 (2b) and 6104717698 (stage 3) and the seat's answers (C on the regime row; the anchor refusal as ③.7's carrier; the hold on 112937e5) asked for. Read on GitHub only: the card body and every comment through 6105234790 (the dev's addendum for this head), the PR body, its 17-file list, the net diff against the merge base 149294c0 (+1,279 / −73), the diffs of the two intermediate heads 112937e5 (20 files) and 2c2b3f49, and the check-runs on this head and on 112937e5. main was read at 149294c0 for the guard's ruled ledger hooks, the two isRowActive readers left, the regime module and its pins, the S10 pin in position-write-through.test.ts, and the anchor-name constants in @objectstack/spec. Every judgment of 6104861792 carries over unless the delta moves it. The delta against 2c2b3f49 is 6 files; the 11 others are byte-identical, hunk for hunk.

① Derived judgments

  1. Carried over, unmoved. dispatcher-plugin.ts, actions.ts, activation-gate.ts, security.ts, tenancy-posture-outage-gates.test.ts, check-route-envelope.mjs, the matrix docblock, the blind-spot census and the door's changeset body (bar one bullet, ①.2) are the hunks the PASS 6104861792 judged; engine-double-contract.pinned.json moved only its hunk offsets. So the accept set of POST /api/v1/security/_activation/:type/:name (6104861792 ①.1), the no-widening against [Decision] 切换后谁能在 Setup 里新建/编辑岗位和权限集:保持今天的权限(持 manage_metadata 者),还是放开给组织管理员(安全边界) #22621 → A (①.2), the name space (①.3), the authority-first ordering (①.4a), the anchor-guard carrier verdict (①.5: no definition is written, so no describeAnchorForbiddenBits gate is owed), the public-surface enumeration (①.6), the exclusions (①.7) and the system-context write (①.9) stand as written there.
  2. The anchor refusal (③.7's carrier) — right. catalog-activation.ts gains step 2b: for type === 'position' and a name in AUDIENCE_ANCHOR_POSITIONS (imported from @objectstack/spec/identity, the constant builtin-positions.ts registers the anchors from, not a literal), the door answers 400 through deps.error, naming the anchor and ADR-0090 D5/D9, for both enabled values. It sits after the two authority gates and the shape check, before the body is read, before the lookup and before the write, so a walled tenant admin still meets the 403 first and nothing is read or written for a refused anchor. The accept set of 6104861792 ①.3 narrows by exactly those two (position, name) pairs and nothing else: permission/everyone is an ordinary name (404 when undeclared, pinned). Pinned in catalog-activation-door.test.ts (2 anchors × 2 values → 400 and no write; authority-first under isolated → 403; the anchors are declared in the test catalog, so the 400 is not a 404 in disguise). The route-ledger note, the changeset's refusal bullet and authorization.mdx say so. This closes the escalation: the anchors are once more sealed against every caller-reachable write, which is the state stage 1's ACCEPT measured.
  3. The 2b × 2c pin, rewritten to the ruled pair — right against the guard on main. On main 149294c0 the guard's ledger hooks (guardActivationLedgerInsert for an inserted row that is off, guardActivationLedgerUpdate for a standing-key update) run enforceStanding and refuse only when no administrator who can sign in would remain; stage 1's flat refuseLedgerSwitchingAdminOff is gone (0 references). The dogfood file now pins both legs on the booted showcase: with every sys_member owner row demoted to member (restored in finally), switching admin_full_access off is 403 PERMISSION_DENIED and no row lands; with the owner standing, the same switch-off is 200 and one row reads off, restored to active: true in finally through the engine with the system context (the door could not be used, because with the set off the caller may no longer hold manage_metadata) and re-asserted on. An anti-vacuity check demands at least one owner to demote. Standing under single is the seeded admin's unscoped grant plus the Default Organization's owner membership, which is what 6103398921 described.
  4. No position regime row on this head — right, and the only shape that keeps every pin. 112937e5 added PACKAGED_BASE_REGIME.position (switchOff only, notFor the anchors) and turned Test Core (3/6) red on a pin this PR may not move: plugin-security position-write-through.test.ts:535 (S10, feat(plugin-security)!: under single, a Setup position create or rename into a name a package or a built-in holds answers the metadata door's refusal (C2 stage S10) #22582) classifies the door's edit refusal for a packaged position as 'edit' by the words source / redeploy, and the row's switch-only sentence carried neither. Measured with an 88-character name: the generic seal is 417, the seal plus the switch 571 (over the 500-character bound), a Regime C sentence with an edit leg beside the switch 437. The 437 form is rightly rejected: regimeCPrescription composes clone and switch legs only, and "never a redeploy" is the regime's own pinned contract (expectRegimeC asserts no redeploy; the docs say "not a redeploy" for every Regime C type), so an edit leg would change the regime, not one row. The seat's second option therefore applies: the row, its changeset and its pins are withdrawn (packages/metadata-protocol is no longer in the diff), a packaged position's refusal keeps the managed seal's "Edit the source artifact and redeploy", the S10 pin is untouched, and permission stays clone-only (seat answer C). With the row gone, the name-blind ITEM_LOCKED arm (readOnlyBaseOverrideError) no longer has a position switch to name for the anchors, so that gap is closed with it. The ADR-0131 D6 pre-chart of position on Regime C stays unbuilt in the refusal table; the switch exists at the door and the docs say so (①.5).
  5. The docs on this head are true of the code. metadata-lifecycle.mdx: one sentence after the Regime C table names the switch for both types, POST /api/v1/security/_activation/:type/:name, as outside the refusal; says the refusal does not name it and that a packaged position's refusal keeps the managed seal's edit remedy (true: no row); and says the anchors are not switchable (true: ①.2). No position row is added to the table, which is right while the refusal names no path for it. The "yet" of 2c2b3f49 is gone. authorization.mdx names the route, the body, the authority, the 404 and the anchors' 400.
  6. The merge. The merge base is 149294c0: main with 2b (e7e6544a) and stage 3. The net diff holds no file of 2b or stage 3 and no generated path. 6b-1b (7aa8d51c, 03:16Z) landed after this head's merge of main; GitHub reports the PR mergeable against the current main (mergeable_state: blocked, not dirty), and 6b-1b's one shared file, authorization.mdx, is edited by this PR in a different paragraph. The queue merge-tests it.

② Semver level

  • .changeset/15204-s2c-catalog-activation-door.md: @objectstack/runtime minor, Clause-②: yes, unchanged in level since 6104861792; its refusal bullet now names the anchors' 400. Right.
  • The @objectstack/metadata-protocol changeset of 112937e5 is withdrawn with its row; nothing in that package is in the diff, so none is owed.
  • @objectstack/spec is only imported (AUDIENCE_ANCHOR_POSITIONS, GUEST_POSITION, through the ./identity subpath package.json exports); @objectstack/core only imported; @objectstack/dogfood private; docs, a comment and two scripts repo-owned. No further changeset is owed.
  • Clause-②: yes matches the diff and the PR body's second line. ADR-0087 marker: none owed and none present.

③ Boundary flags

  1. Q1 / Q2 as in 6104861792 ③.1–③.2. The PR body's first line still reads "Stacked on stage 1 …", left alone per the seat: stale prose, not a published page.
  2. Test Core (3/6). Red on 112937e5 for a cause in this PR (①.4), so that head was not signed; the fix is the row's withdrawal, not a rewrite of the pin. On a27c4db8 Test Core (3/6) and the Test Core roll-up conclude success, so the S10 pin and the rest of plugin-security's suite are green on the landing head, and the plan.boot-parity sys_activity case of the seat note 6104322014 is green for the second head running. The three Dogfood Regression Gate shards and their roll-up are success, which is where the ruled pair and the booted anchor case run.
  3. Docs owed by the Landed record and the seat's C: delivered (①.5). The "yet" nit of 6104861792 ③.4 is closed.
  4. row-active.ts after 2b: the reader list now names only plugin-security's explain-engine.ts and security-plugin.ts, and says the guard and the sharing check read the ledger. Checked on main 149294c0: last-admin-guard.ts 0, sharing-rule-service.ts 0, explain-engine.ts 4, security-plugin.ts 3, resolve-authz-context.ts 0 references to isRowActive. Comment only; no changeset owed.
  5. 2b × 2c pin rule: satisfied on this head (①.3). The landing-order condition of 6104861792 ③.6 is discharged: this head is built on main after 2b.
  6. The anchors (6104861792 ③.7): closed by the door's refusal (①.2).
  7. Left open by the withdrawal, for a later carrier, not this PR: a packaged position's sealed refusal still names no switch while one exists. Making the regime table say so needs either a ruling that lets a Regime C row carry an edit remedy beside a switch (the 437-character form, against the regime's pinned "never a redeploy") or a seal sentence that fits both inside the 500-character bound (571 today), plus a name-aware ITEM_LOCKED emitter for the anchors. All of it is refusal wording; none of it moves an accept set, and the S10 pin stays as it is in every form.
  8. check:platform-checklist as in 6104861792 ③.9 (red on main too; neither file is in this diff).
  9. Check-runs on this head, all complete: 35 runs, 33 success, 2 roster skips (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure. Green: all six Test Core shards and the roll-up, all three Dogfood Regression Gate shards and the roll-up, Dogfood Verify CLI, Lint & Repo Gates, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, the four Type Check gates and TypeScript Type Check, Spec property liveness, Temporal Conformance, Governed Surface Queue Guard, Flag docs affected by code changes, and the five PR-form gates. No red is left to attribute. (On 112937e5, the superseded head, the same roster had one failure, Test Core (3/6), judged this PR's in ①.4.)

Implemented-by: session_015LutkhFn8wzgD5J86PtpYN
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants