Skip to content

feat(spec,core)!: positions declare their permissionSets; the authorization resolver reads the security catalog and the activation ledger - #22723

Merged
objectstack-fleet[bot] merged 33 commits into
mainfrom
claude/issue-15204-s1-position-permission-sets
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 33 commits into
mainfrom
claude/issue-15204-s1-position-permission-sets

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15204
Clause-②: yes

Size: 3,776 changed lines (115 files, +2,997 / −779 against origin/main at c63028e5bf), over the 3,000 target, delivered as the dispatch allows. About 650 of them are nine copies of a 72-line test helper (security-catalog.testkit.ts, one per package). One shared helper, for example a ./testing subpath on @objectstack/core, would bring this under 3,000. That adds a published package surface, so it is left to the PM rather than done here (additions plus deletions, generated files and test deletions included).

Stage 1 of the ADR-0131 cutover batch (stage plan 6094501866; #22601 → B, #22006 → B). This PR lands the half that stage 1 owns: PositionSchema.permissionSets replaces the closed-shape refusal, and resolve-authz-context reads positions, the sets they name and the set bodies from the security catalog (createSecurityCatalogReader). The junction read and the id bridge are gone. Stages 2 to 8 stay on #15204, which stays open: the services readers, the boot report, verify, objectui C9, the seeder deletions, the projector and write-through retirement, and the table retirement. #15204 remains open after this merges.

What changes

@objectstack/spec. PositionSchema.permissionSets is a list of permission-set names (snake_case). The guidance.permissionSets refusal at position.zod.ts is gone, and the permissions refusal now points at the new key. The generated reference page, the package-api-assembled page and the liveness ledger row (position/permissionSets, live, evidence resolve-authz-context.ts#positionSetNamesOf) come with it. A new ADR-0087 semantic entry, position-permission-sets-declared, carries the migration prescription.

@objectstack/core, the resolver. resolveUserAuthzGrants takes the catalog that securityCatalogReaderOf(ql) answers. The security plugin binds it with bindSecurityCatalogReader.

  • §6a: each held position's definition is resolved by name. The sets its permissionSets names are what the position grants. The sys_position_permission_set read and the sys_permission_set read by the junction's ids are deleted.
  • §6/§6b: every held set's body (systemPermissions, tabPermissions) is its catalog definition. A name the catalog does not hold confers nothing.
  • ADR-0049 deactivation is read from the activation ledger (ADR-0126 §4), as the PM supplement directs: one sys_metadata_activation read of types position and permission, keyed by every name the resolution may grant, limit two rows a name (the ledger is unique on type and name). A row whose active is false switches the name off, deployment-wide. ⛔ The sys_position and sys_permission_set rows are not read at all, their active column included: no row fallback. A composition whose registry has no ledger object (no PlatformObjectsPlugin) issues no ledger read; its engine would answer "object not found", which tryFind reports as a 503 (pinned).
  • sys_metadata_activation joins the grants-cache watch set.
  • Platform standing: an organization-less admin_full_access user grant, the set resolved in the catalog, and not switched off in the activation ledger.
  • An engine with no catalog bound resolves no set and no position-bound set. That matches what an engine with no permission tables provisioned resolved before.

The conversion function for C7b (#15211) and C7a (#22617, PR #22643). It is exported from @objectstack/core, so packages/cli imports it with no cycle:

convertPositionBindingRows(input: PositionBindingConversionInput): PositionBindingConversion
input   = { positions, permissionSets, bindings, declared? }   // the three tables as read; declared: name -> the permissionSets a definition names today
output  = { positions: PositionBindingFate[], dangling: DanglingPositionBinding[] }
fate    = { name, fate: 'declared' | 'converted', permissionSets } | { name, fate: 'conflicting', byOrganization }
convertDeactivatedCatalogRows(input: { positions, permissionSets }): CatalogDeactivationConversion
output  = { ledger: { metadata_type: 'position' | 'permission', name, active: false }[], conflicting: { metadata_type, name, byOrganization }[] }

Both functions are pure. The deactivation conversion turns the rows' ADR-0049 active: false flags into ledger rows; a name some organizations switched off and others kept on is conflicting (the ledger is deployment-wide), and the applier supplies the package_id the ledger object requires. converted is the definition's list followed by the rows' sets: a binding only adds. When organizations holding rows of one name bind different sets (a row with no binding binds the empty list), the name is conflicting and is never merged (D10 fate 4). A binding to a missing position or set row is dangling. ⛔ Neither is wired into boot. The pin is position-binding-conversion.test.ts (7 tests), and the batch-equivalence testkit drives every fixture's junction rows and deactivated rows through them.

@objectstack/plugin-security.

  • start() binds the catalog read (engine registry and metadata service) to the engine, and says so at warn if it cannot.
  • At kernel:ready, the built-in everyone declares the deployment's baseline in its permissionSets: the app's isDefault set and member_default. That is where bindBaselineToEveryone ran, with its own high-privilege check and the stack's declared-capability context (A declared app capability token is still refused at the everyone anchor: describeHighPrivilegeBits's consumers pass no declaredCapabilities (PR #17811 landed the spec predicate only) #18535). A new pin shows the definition and the boot binding agree.
  • The name-fold warning (reportNameFoldCollisions) named a junction row as the remedy, which is now false; it names the position definition's permissionSets.
  • position-write-through: a Setup row edit carries the stored definition's permissionSets into the definition it saves (the row has no column for it). Before this change no definition could carry the key. Without the carry, a label edit would silently clear a binding the metadata door declared. Proven by ablation.

@objectstack/plugin-auth (a deviation, stated). The admin-standing surface core measures is now sys_metadata_activation (metadata_type, name, active) where it was sys_permission_set, and plugin-auth's correspondence gate (last-admin-standing-keys.test.ts) demands a standing-key list, and so a hook, for every table the resolver derives standing from. So STANDING_KEYS_BY_TABLE carries the ledger in place of sys_permission_set, and a new hook refuses a ledger write that switches admin_full_access off (refuseLedgerSwitchingAdminOff, fail-closed when the target row cannot be read; pinned in last-admin-guard.activation-ledger.test.ts). It refuses outright, with no enumeration. The guard's own enumeration, its sys_permission_set hooks and the deactivated-admin_full_access diagnostic are untouched (measured below).

Examples. The eight showcase personas and the three CRM personas declare permissionSets. Both bind-position-sets.ts binders are kept, with a pin that the junction pairs equal the declarations (inert-wirings.test.ts, the new test/position-bindings.test.ts). The reason is measured: the stage-2 readers still read the junction (below). Deleting the binders here would take those positions' sets away from the delegated-admin gate. The stage that removes the last junction reader deletes them.

Equivalence (the stage's acceptance test)

resolve-authz-context.batch-equivalence.test.ts: all 11 fixture principals resolve a byte-identical grants envelope to the recorded capture. The recording double now carries the catalog the fixture's own rows convert to (convertPositionBindingRows). They cover the platform administrator (permission-set-derived-grants), the organization administrator (tenant-admin-via-position), members (multi-org-membership, position-derived-grants, …), a custom-set holder (position-derived-grants), the seeded API-key principal and the empty principal.

The recorded moves are written in the file header. In the two fixtures whose positions bind sets, the junction read and the read by the junction's ids are gone. In every fixture the sys_position read and the by-name sys_permission_set reads become one sys_metadata_activation read; the recording double serves the ledger rows the fixture's own active: false rows convert to (convertDeactivatedCatalogRows), so the deactivated retired position and dead_set set still grant nothing. Every fixture resolves in 2 legs. Ablation: with the converted ledger rows withheld, position-derived-grants and permission-set-derived-grants go red on the grants envelope.

The delegated administrator and the guest have no separate resolver path. A delegated admin's adminScope is not part of the envelope; it is a set like any other here. The guest is assembled outside resolveUserAuthzGrants. So neither adds a fixture shape the 11 do not cover.

Booted goldens (plugin-security):

  • grant-permission-set-name.equivalence, resolve-authz-grant-set-by-name.golden, grant-readers-by-name.golden, backfill: unchanged.
  • builtin-positions.boot (a booted kernel) records two moves, both written in its header:
    • Under a wall, everyone now also names the organization-less member_default. The per-organization binder could never bind it, because there was no own-organization row. member_default carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. So the envelope gains a name and nothing it grants.
    • With a stored everyone definition shadowing the built-in (bare-slot precedence), that definition's empty list is what the catalog serves. A v18 boot refuses such a stored row (os migrate security-catalog-overlays).

Junction writers (enumerated; positive control: the resolver's own former junction read counted 1 at base 86da194, 0 now)

writer where fate in this PR
bindBaselineToEveryone security-plugin.ts closure replaced as the grant source by everyone's declared baseline (same check). The closure itself is untouched: only delete, never fix; stage 6a deletes it
built-in and declared position seeders bootstrap-builtin-positions.ts, bootstrap-declared-positions.ts write no junction row (insert sites measured)
showcase and CRM persona binders examples/*/src/security/bind-position-sets.ts bindings declared on the definitions; binders kept for the stage-2 readers, pinned equal
Setup, position page "Bind permission set" platform-objects/src/pages/sys-position.page.ts related list, through the generic data door rows no longer grant: open question 2
audience-binding suggestion confirm suggested-audience-bindings.ts:990 rows no longer grant: open question 2

objectui main (023f00d) reads the junction in AssignedUsersSection.tsx and writes it nowhere; the Setup write goes through the objectstack page's related list.

Junction readers left for stage 2 (measured)

delegated-admin-gate.ts (setsBoundToPosition :1248, used by the assignable-positions listing, assertAssignmentWrite and the D12 step-6 containment check), suggested-audience-bindings.ts:468 (bindingExists) and cleanup-package-permissions.ts:137. The explainer moved with this PR: it builds on resolveUserAuthzGrants. See open question 1.

Open questions (in the report on #15204)

  1. The D12 gate is blind to a definition-only binding until stage 2. The gate's containment check and its assignable-positions listing read sys_position_permission_set. A position whose permissionSets names a set that no junction row carries looks, to the gate, like a position that distributes nothing. ⛔ Not fenced here: the exclusions put this file in stage 2. The example personas keep their rows and are unaffected. Stage 2 must land before any release cut that contains this PR.
  2. Setup and suggestion-confirm writes still produce junction rows, which now grant nothing until C9 plus stage 7 move Setup onto the definition. ⛔ No write-through or projector is built (Zone 1).
  3. Deactivation at upgrade, until C7b ships. The resolver reads the ledger and no longer the rows, so a position or set an organization deactivated through Setup (the row's active) grants again until the upgrade ceremony applies convertDeactivatedCatalogRows. And no door writes the ledger for these types (measured below), so Setup's Deactivate action switches nothing off from here on. C7b and a ledger door for permission/position must land before a release cut that contains this PR.
  4. The explainer still labels "deactivated" from the row (explain-engine.ts, stage 2). After this PR the resolver and the explainer agree only when the rows have been converted to the ledger; the explain fixtures carry the converted ledger rows. Stage 2 moves the label onto the ledger.
  5. The last-admin guard's enumeration reads the row's active (last-admin-guard.ts :1214–1242, the deactivated-admin_full_access diagnostic, and the enumeration it guards). Measured, not changed: it now refuses MORE than it must (a row the resolver ignores still counts as off there), never less, and the ledger half of the risk is refused by the new hook. Stage 2's.

Measurements the PM asked for

  • Ledger write doors. Each ledger store is built with a fixed type: flow (service-automation/src/flow-activation-store.ts, door POST /automation/:name/toggle) and action (objectql/src/action-activation.ts, door POST /actions/_activation/:object/:action, gated by runtime/src/domains/activation-gate.ts). The ledger object's data API is get/list only (sys-metadata-activation.object.ts, enable.apiMethods). No door accepts permission or position. ⛔ Not widened.
  • sys_capability.active. No authorization reader. It is a catalogue flag (sys-capability.object.ts: the Deactivate dialog states authorization is not affected; ruling 2026-08-13). It is written by the boot seeders (active: true on insert) and the Setup activate/deactivate actions, and read by Setup's list views only. Losing it costs a Setup filter and review marker, no enforcement. Nothing changed.

Q3 A promotion path (measured)

An existing write path serves it: the metadata door saves an environment position under the name of an organization's row-only position under isolated, and the catalog then serves it (position-write-through.test.ts, "promoted by an environment definition saved at the metadata door"). No command promotes a row in place. The changeset names the loss and this path.

Coupling with #22628 (C5)

PR #22628 (the metadata read scope createSecurityCatalogReader consumes) merged at 14:03Z and is in this branch through both main merges (the latest at c63028e5bf). The golden and the core suite ran green on top of it (below).

Verification (head 83b54d84f1)

Each heavy run went through scripts/pm/os-verify-lock.sh. Exit codes were captured before any pipe.

Package Result
@objectstack/core (after the second main merge) 2,357 passed; typecheck 0
@objectstack/plugin-security (after the second merge) 4,126 passed / 45 skipped; typecheck 0
@objectstack/plugin-auth (after the second merge) 2,773 passed / 10 skipped; typecheck 0
@objectstack/spec (after the first merge) 20,137 passed / 1 todo
@objectstack/platform-objects 1,082 passed
@objectstack/runtime 5,660 passed / 19 skipped
@objectstack/rest 5,170 passed / 327 skipped
@objectstack/objectql 7,743 passed
@objectstack/cloud-connection 514 passed
plugin-hono-server · plugin-sharing · plugin-approvals 329 · 1,005 · 1,015 passed
service-automation · service-settings · service-datasource · service-storage 2,331 · 755 · 763 · 831 passed
organizations · example-crm · example-showcase 152 · 46 · 409 passed

The other rows ran on this branch before the second main merge (c63028e5bf, stage 6c-prep plus unrelated main work). That merge's changes to those packages are main's own and were not re-run here; CI runs them on the head.

Gates. dispatch-gates.mjs --commands derived 126 families at 83b54d84f1; all 126 ran on that tree. --ran: 126 derived, 126 run, 0 NOT-MEASURED, 0 UNRUN. One family exits 1: check:platform-checklist, with 7 findings (absent symbols in metadata-protocol/src/protocol.ts and service-storage/src/attachment-access-hooks.ts, and the anchor floor). It exits 1 identically on origin/main at d8830c28 (measured in a separate worktree), and this branch touches none of those files. check-plugin-teardown-shape --self-test and check:skill-examples refused at first for a shallow clone and missing client builds; both measured green once satisfied. check:objectql-double-limit caught this PR's own new ledger-guard double, which is fixed. Repo-wide pnpm lint was not run locally.

Ablations. Withholding the converted ledger rows from the recording double reds position-derived-grants and permission-set-derived-grants on the grants envelope; restored byte-identical. The position-write-through carry was ablated earlier, also red then restored.

Acceptance notes

  • cloud-connection, plugin-approvals, service-datasource, service-settings, rest, runtime, plugin-auth, plugin-hono-server, plugin-sharing and service-automation suites had fake engines that granted sets from rows alone. Each now binds the catalog its own rows convert to, through a small per-package security-catalog.testkit.ts. The core one is the reference; ⛔ one is copied per package and never imported across packages.
  • The S5a pins that asserted an organization's set row decides a grant's body or its active flag (resolve-authz-context.grant-set-by-name.test.ts, the #20515 per-organization binding cases, the #8613 ADR-0049 cases) are rewritten to the stage-1 rule: body and existence from the catalog, deactivation from the ledger. Each rewritten case names the rule it now pins, and a new case pins that a row's active: false is not read.
  • cloud-connection's install fixtures answer the authz tables from a grant map; they now answer sys_metadata_activation empty too (5 files), as they answer the others.
  • The position form gained a permissionSets row, so the metadata-form bundles were regenerated and the row is translated in zh-CN, ja-JP and es-ES. The echo-decision catalog count moves 668 → 669 with its note.
  • After the second main merge (stage 6b-1a's registry-declared capabilities), the #18535 test registry stamps _packageId the way the engine registry does, so the declared-capability reader can tell the platform's own items apart.
  • The explain harness in security-plugin.test.ts exposed no engine registry, so the catalog never bound (its own warn said so) and the org-scoped explain case was red on this branch. It now carries a registry over its definitions.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U


Generated by Claude Code

claude added 26 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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests labels Oct 10, 2026
… nothing

The permissions docs, the delegated-admin TSDoc and the page audience refusal
point at the position definition's permissionSets; the suggestion Confirm
paragraph says the row it writes does not grant; the grants-cache watch list
names sys_metadata_activation.

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

# Conflicts:
#	packages/spec/src/migrations/registry.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Type Check · source gates is red on 73c76fb1, and the cause is not this PR's. Epic PM session_01Rerax7QTjKMPCUZxQUtPFR · 2026-10-11T00:07Z

…migration registry

Byte-identical to that PR's head 2a4ccb4 for render-projection-diff.ts and
its test; a no-op once main carries it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGgRrdom7nizSbHc5Gws2U
…ctive no longer decides a grant

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 23d3545e641ded76978eb351618d574609ca31fe
Local-runs: none

Third record on this PR; it supersedes 6102570656 (PASS on 96fb3e98) and judges the landing head. Reviewed read-only: the 35 check-runs on this head, the PR (130 files, +3,226 / −867 = 4,093 changed lines, draft, no auto-merge, mergeable_state: clean, base ed1de8c2db = main's head at posting time), the compare 96fb3e98...23d3545e commit by commit (fb2855fb, 2c3a5988, bf6bdf07, 73c76fb1, d0cab9f8, 23d3545e), the files those commits touch as served at this head, the tree at this head, and card #15204 through comment 6103991518 (the seat's ACCEPT 6102862135 and the dev addenda 6103145911, 6103572180, 6103608484, 6103784577 included). The twelve contract judgments of 6101868038 ① and the delta judgments of 6102570656 ① stand unchanged; this record judges what moved since.

Gate verdicts on the head, read at posting time. All seven required contexts are success: Lint & Repo Gates (01:02Z), TypeScript Type Check (00:50Z), Test Core (01:03Z, aggregate and every shard), Dogfood Regression Gate (00:44Z), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Of the 35 runs, 33 are success and 2 are roster skips (Console Pin Gate, Packed-tarball smoke (opt-in)); every run is completed. The maintainer's review 5481027860 (os-zhuang, APPROVED, 22:27Z) is listed on the PR and not dismissed, which is the landing instrument a 4,093-line PR needs (Multi-agent discipline §7 class (c)); this record is the contract-tier review beside it, not a second approval. Nothing is armed, approved or readied by this review.

① Derived judgments

The delta since 96fb3e98 implies no new accept-set or public-surface change. Each piece, judged:

  1. fb2855fb (changeset, +4 / −1): '@objectstack/platform-objects': patch with one sentence for the position form's translated permissionSets row — right: the regenerated bundles ship in that package's dist. The row-only clause now reads "including a row inserted directly (a script or a harness, outside the product's doors)" — the clause 6102570656 ③ suggested, adopted.
  2. 2c3a5988 (docs and TSDoc, 10 files, +41 / −28): each changed sentence read against the diff and against ADR-0131 D3/D4 — true on this head: positions.mdx (intro, permissionSets in the example), permission-sets.mdx (the definePosition form in place of the junction insert; "a name the catalog does not hold confers nothing; a sys_position_permission_set row confers nothing either"; the suggestion-Confirm paragraph: the row it writes does not grant until the set is named in everyone's permissionSets), profiles.mdx, objects.mdx:336 ("whose rows no longer grant"), environment-variables.mdx (the watch list gains sys_metadata_activation, as GRANTS_CACHE_WATCHED_OBJECTS does), authorization.mdx:37 and :236, delegated-administration.mdx:161 with the same sentence in membership-role.ts, and page.zod.ts's two refusal strings regenerated into references/ui/page.mdx (the hunk is that sentence alone). Two nits, neither a verdict carrier: authorization.mdx:37 now lists permissionSets under "Runtime records, maintained by admins in Setup", and until C9 that key is written at the metadata door by a platform administrator; the membership-role sentence ("with no position of that name declaring permissionSets it resolves to nothing") is as true as its predecessor, both passing over the Permission-set resolution binds by POSITION NAME while sys_position_permission_set sits near-empty — confirm name-based resolution is the intended mechanism, not an accident the empty junction hides #13419 name fold, which still resolves a same-named permission set and warns.
  3. bf6bdf07 (landing merge round 1, origin/main 7c7e7505): clean; main's twelve commits since c63028e5 touched none of the sixteen merge=os-regen paths, and the PR's own four generated paths kept byte-identical patches — right. Superseded by the next merge before it could land (main moved to ed1de8c2, which deletes a file this PR edited; GitHub read dirty).
  4. 73c76fb1 (landing merge round 2, origin/main ed1de8c2): the one conflict, packages/spec/src/migrations/registry.ts (deleted by build(spec): the migration registry is generated at build and leaves git #22706: the registry is generated at install and build from registry.ts.template plus entries/, and git-ignored), is resolved as a deletion — right, and it is the only correct resolution: the PR's former +33 hunk there was the generator's copy of entries/semantic/18.position-permission-sets-declared.ts, which stays tracked at this head (blob fe1e01d58fa4), while registry.ts is untracked (not in the tree; .gitignore lines 68–69) and registry.ts.template is tracked. position-permission-sets-declared is therefore registered the way the generated era registers an entry, and check-adr-0087-registration (which in this era runs the generator as its witness) is green inside Lint & Repo Gates on this head. The two os-regen paths main moved between 7c7e7505 and ed1de8c2 (liveness/state-counts/dashboard.md, state-counts/view.md) are absent from the PR's merge-base diff, so their main content stands; the merge commit's diff against its first parent carries main's deletion of registry.ts (−30,765) and nothing of this PR's is lost (item 7 below).
  5. d0cab9f8 (port of fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750): packages/spec/scripts/render-projection-diff.ts and render-projection-diff.test.ts — blob 07690f29a8ee… and e095311e290c… at this head equal the blobs at fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750's head 2a4ccb4878 (byte-identical), the commit touches nothing else (2 files, +158 / −3), and fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750 itself is a 2-file PR of the same blobs. Right: it repairs main's own Type Check · source gates breakage ci(spec): render-projection-diff's base archive lacks the generated registry.ts since #22706, so every merge-queue entry fails Type Check · source gates #22744 (a base archive without the git-ignored registry), it is a repo script outside any published entry so no changeset is owed, and it becomes a no-op merge once fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750 lands. The seat's standing-down note is 6103602317 on the PR.
  6. 23d3545e (patch round 3, authorization.mdx "Grant lifecycle: the active switch", +27 / −22): sentence by sentence against the resolver and the guard at this head —
    • the switch is a sys_metadata_activation row of type permission or position whose active reads false, deployment-wide, filtered at resolution time, fail-closed, in resolveAuthzContext, no cleanup job — true (readDisabledCatalogNames, §6a/§6b);
    • a catalogue row's own active no longer decides a grant, and Setup's Deactivate writes that column, so it switches nothing off — true (no row fallback; no activation door accepts these two types on this head: stage 2c, PR feat(runtime): activation door for positions and permission sets on sys_metadata_activation (stage 2c) #22736, is open and unmerged);
    • a deactivated set contributes neither its name nor its system_permissions / tab_permissions; a deactivated unscoped admin_full_access derives no platform_admin, the flag applied before the posture; a configured administrator never reads the ledger — true (take(body, active), then psRowsAll filtered on its active flag; active && in the standing condition; the config anchor reads config plus sys_user);
    • a deactivated position stops carrying its sets and leaves positions — true (grants.positions filtered before boundSetNames is built);
    • assignments untouched; switching the ledger row back on restores at the next resolution — true;
    • "Absent is ACTIVE", and the last-administrator guard's enumeration still reads the catalogue row's active, so it refuses more, never less — true (6101432403 Q5);
    • a ledger write switching admin_full_access off is refused outright whatever the administrator population, one whose target row cannot be read is refused, switching it back on is never refused — true (refuseLedgerSwitchingAdminOff, pinned, and measured on a booted showcase by stage 2c's dogfood pin);
    • the closing paragraph (administration surfaces keep listing a deactivated item; the anchor and delegated-admin gates keep reading every row) — true.
    • One paragraph is not true for the ledger switch on this head, the explain-engine paragraph (lines 426–436), left unchanged by the rewrite. Measured: explain-engine.ts at this head derives state: 'deactivated' from isRowActive(row) on the sys_position / sys_permission_set rows (:550, :584), never from the ledger. So a set or position switched off in the ledger is dropped by the resolver but is not reported deactivated by the explainer, and a row Setup deactivated is reported deactivated while the resolver still grants it. The PR recorded this divergence itself (body open question 4; 6101432403 out-of-scope finding; the explain fixtures carry converted ledger rows beside the rows so both faces agree there), and the card gives the explainer's move to stage 2a (6101897723; PR feat(plugin-security)!: the security readers read the catalog and the activation ledger (ADR-0131 cutover stage 2a) #22751 in flight), which rewrites that paragraph when the explainer reads the ledger. Judged a carried docs item, not a verdict carrier: it describes the diagnosis surface's interim, not an accept set or an enforcement claim, and every enforcement sentence of the section is true. The one-clause fix, for whichever push touches the page first (stage 2a's, or a docs-only push on this PR at the seat's option): "until the explainer moves onto the ledger (stage 2a), its deactivated state follows the catalogue row's active, not the ledger row".
  7. The PR's diff against main is this PR's alone. The merge-base file set is 130 = the first head's 115 + patch round 1's 4 test files + patch round 2's 10 docs and TSDoc files − registry.ts + the 2 ported files; no main-side file is in it. Against bf6bdf07, 127 patches are byte-identical and exactly one differs (authorization.mdx, this round). Against the first head 83b54d84, every one of the original 115 patches is identical except the files the patch rounds named. No os-regen path dropped a main-side entry: the os-regen paths main changed across both merge rounds (none in round 1; the two state-counts files in round 2) are absent from the PR's diff, which is what "content equals main" looks like on a merge-base diff, and the PR's own generated paths carry only their original hunks.
  8. Scope: no governed surface in the file list (check-governed-merges reads 0 of 130 per the seat), nothing under content/docs/releases/, no row deleted, no table dropped, no transition piece. The PR stays Part of #15204, assignee marchtian.

② Semver level

Unchanged in substance from 6102570656 ② and still right: @objectstack/spec, @objectstack/core, @objectstack/plugin-security, @objectstack/plugin-auth at minor, and now @objectstack/platform-objects at patch; body BREAKING with FROM → TO; Clause-②: yes on the PR body's second line and in the changeset, matching the claim (6095611212) and the diff (PositionSchema accepts a key it refused; no arm, the accept-set movement being a widening). The ADR-0087 disposition registered position-permission-sets-declared resolves in the generated era to the tracked entry file entries/semantic/18.position-permission-sets-declared.ts (①.4), and Check Changeset and Lint & Repo Gates are success on this head. The ported script publishes nothing. The delta publishes nothing further.

Clause-②: yes

③ Boundary flags

  • 6101868038's three conditions (the four red shards green; the ADMIN_STANDING_SURFACE sentence; the release-cut condition on the card) were met at 96fb3e98 and still hold here; the release-cut condition is extended by the seat's ACCEPT 6102862135 to (a) stage 2 with 2c's activation door, (b) C7b, (c) C9 with stage 7, (d) the suggestion-confirm path — all recorded, nothing further owed by this PR.
  • The audience-anchor guard (stage 0 row 13(b)): measured on 96fb3e98 and recorded in the ACCEPT; no file that measurement rests on changed in this delta (security-plugin.ts, position-write-through.ts, registry.ts of objectql, protocol.ts, builtin-positions.ts are untouched since), so the seal holds here as there, with the same carrier (a definition-write gate on describeAnchorForbiddenBits the day an environment write path for an anchor opens) and the same residual (a bare-slot sys_metadata row written straight into the database; the CLI reader exists, the boot refusal does not).
  • Landing-round items, verified: registry.ts resolved as a deletion with the entry file as the carrier (①.4); the fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750 port byte-identical and confined (①.5); the PR's docs now describe this head except the one explainer paragraph (①.6), carried to stage 2a or an earlier docs-only push at the seat's option.
  • Size 4,093 changed lines: class (c); the maintainer's APPROVED review 5481027860 is on file and the owning seat lands it. ⛔ Not armed, not approved, not readied by this review.
  • Flag raised by this review, for the seat's landing sequence: this head's base is main's head at posting time, so a further main move before the queue takes it means another merge, and the queue rebuilds against main in any case; if fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750 lands first, the port merges as a no-op.

Implemented-by: session_01AGgRrdom7nizSbHc5Gws2U
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 01:12
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 01:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit bfc15d2 Oct 11, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15204-s1-position-permission-sets branch October 11, 2026 01:48
This was referenced Oct 11, 2026
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 protocol:ui size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants