Skip to content

fix(core): the effective map folds a super-user wildcard per set only — no cell checkObjectPermission refuses (#20136) - #20165

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20136-super-user-fold-over-grant
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20136-super-user-fold-over-grant

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20136

Clause-②: no

The effective object-permission map (buildEffectiveObjectPermissions in @objectstack/core: the objects slot of GET /auth/me/permissions, and what ISecurityService.getEffectiveObjectPermissions hands to current_user.can() on the write path) no longer grants any cell that PermissionEvaluator.checkObjectPermission refuses, for every super-user shape measured. This completes the family's closing assertion: 0 over-granted and 0 under-granted cells over every super-user subject in the parity table.

What was wrong

Before its per-set super-user fold, the builder ran foldWildcardSuperUser over the MERGED map. That pass put the merged bypass bits on every entry:

  • into an entry the super-user set names ITSELF, where resolveObjectPermission answers that set with its explicit entry (the walled organization_admin's read-only RBAC rows and write-denied identity tables, an explicit {} entry, an export-only entry);
  • allowCreate on modifyAllRecords alone, which the spec's objectPermissionGrants gives no create cell.

Mechanism hypotheses H3 are both CONFIRMED, by reading and by measurement: removing that pass alone takes every row below to 0, and adds 0 under-granted cells anywhere.

What changes

packages/core/src/security/effective-object-permissions.ts:

  • buildEffectiveObjectPermissions no longer calls foldWildcardSuperUser. The super-user fold is foldSuperUserWildcardGrants alone (added by core: effective-map super-user entries never carry transfer (or a super-read wildcard's plain bits) — current_user.can(obj, 'transfer') is false for admin_full_access where enforcement answers true via modifyAllRecords #20134): per set, onto the entries that set does not name, exactly the bits objectPermissionGrants says that set's wildcard grants. Another set's super-user wildcard still widens an entry bit by bit, as checkObjectPermission combines sets. The seed, plain-wildcard coverage, clamp and annotation are untouched.
  • foldWildcardSuperUser keeps its name, signature and body. It is a released export of @objectstack/plugin-hono-server (re-exported from core). Its docblock now says it is not a step of the map, and names where it is broader (own narrower entry, create) and narrower (transfer, a super-user wildcard's own plain bits) than the server. Retiring it is an export removal, so it is left as an open question below rather than done here.
  • Docblocks that named the merged fold as a map step (wildcardGrantsSuperRead, the seed, plain coverage, the clamp, the builder) now name the per-set fold.

Tests:

  • plugin-security get-effective-object-permissions.test.ts: KNOWN_OVER_GRANT, its flip-trigger assertion and its "names a row" test are DELETED rather than emptied. The table now holds every subject EXACT in both directions, with the managed-write clamp's narrowing as its only allowance. Each enumeration shape is a named subject, tagged with its row ([#20136 row a] to [#20136 row h], plus the existing one-set row), so a regression names its row. Rows b, d, e, f, g and h are new. The apiOperations column runs over them too, which is where row e's offered-but-refused export is held. One spelled-out case: the walled org admin may not create, edit or delete sys_position, and can() says so, with and without member_default; sys_user edit without it.
  • core effective-object-permissions.test.ts: a [#20136] block (own read-only entry, {} entry, export-only entry under a super-read wildcard, modifyAllRecords without create, another set's wildcard still widening). Two composition fixtures that relied on the over-grant are re-spelled: the clamp and fold cases now put the narrower entry in a SECOND set, so they still exercise the fold.
  • plugin-hono-server: the endpoint fixture's sys_member deny moves to the second set, so its "clamp over the fold" assertion keeps exercising the fold. There is a new endpoint case, a super-user set's own narrower entry served as that set's answer, byte-equal to the builder. fold-wildcard-superuser.test.ts gets a note that it pins the standalone released helper.

Changesets: .changeset/20136-super-user-fold-per-set.md (@objectstack/core patch), plus three one-clause DELIBERATE CORRECTIONS of pending notes, listed below.

Measurement

The map answer is the real formula current_user.can() over toEvalPermissions(map). It is compared with PermissionEvaluator.checkObjectPermission on all 10 verbs. over means the map grants a cell the evaluator refuses, and under the reverse. As in the existing pin, create, edit and delete refused on a guarded managed object is the managed-write clamp and is not counted. The fixture has 61 registered objects: every object schema exported by @objectstack/platform-objects and by plugin-security's objects, plus 4 app objects (public, apiMethods-restricted, private, and apiEnabled: false).

  • Base: 49144fccc8, with core's dist/ built from it.
  • Head: 6527062a22.
  • A second base leg (the producer file restored from base, core rebuilt, the marker proven present in dist/) is byte-identical (sha256) to true base on all 27 subjects.

Enumeration (over-granted cells, base to head)

row shape base head
a walled organization_admin + member_default 38 0
b walled organization_admin alone 44 0
c '*': { modifyAllRecords: true } alone 32 0
d {} explicit entry inside a super-user set 8 0
e '*': { viewAllRecords } over its own { allowExport: true } entry 2 (+1 export offered and refused) 0 (+0)
f (new) '*': { viewAllRecords } over its own narrower entry 1 0
g (new) modifyAllRecords-only '*' beside a set naming the object narrower 2 0
h (new, control) admin_full_access + organization_admin + member_default 0 0
existing one set: a super-user '*' and a narrower crm_account 7 0
  • Rows a, b and d reproduce the card's 38, 44 and 8 exactly.
  • Row c is 32 here and 36 on the card: it is create + import on every object the clamp does not narrow, 16 on this fixture and 18 on the card's 63-object fixture.
  • Row f: the super-read path over the set's own entry, with no modifyAllRecords and no export.
  • Row g: the create lift arriving from a SECOND set's wildcard, over an entry the first set names.
  • Row h: the control row, where another set's super-user wildcard legitimately widens a narrower entry.

Parity (all 27 subjects)

The 18 subjects of the #20083/#20134/#20135 table, rows a to h, a super-read wildcard alone, admin_full_access alone, and a super-read wildcard beside a plain one:

  • over: 8 subjects were non-zero at base (the table above). Every subject is 0 at head.
  • under: 0 at base and at head, in every subject. No cell checkObjectPermission grants became refused in the map.
  • apiOperations column (offered and refused, and served but hidden): row e was 1 offered-and-refused at base (crm_account export). It is 0 at head, and every other cell is 0 at both.

Reach, at a public door (H2)

The write path is PR #20079's ObjectQL write path: the real engine, with the real SecurityPlugin resolver (the function it registers on the engine) as its resolver. crm_case.stage has options gated on current_user.can(…).

subject gate base head checkObjectPermission
walled org admin + member_default can('sys_position','edit') ADMITTED refused VALIDATION_FAILED / invalid_option false
walled org admin + member_default can('sys_position','create') ADMITTED refused false
walled org admin alone can('sys_position','edit'), can('sys_position','create') ADMITTED refused false
walled org admin alone can('sys_user','edit') ADMITTED refused false
'*': { modifyAllRecords } can('sys_position','create'), can('crm_account','create') ADMITTED refused false
the other 18 cells of the 25: the controls admin_full_access + member_default and member_default, and every cell the server grants unchanged unchanged agrees

GET /api/v1/auth/me/permissions was served by the real registerCurrentUserEndpoints on a Hono app over the same resolved sets and registry.

subject objects.sys_position create / edit / delete response bytes objects sha256
walled org admin + member_default true / true / true → false / false / false 13187 → 13203 7d78b7069a14 → 7c41ba445efa
walled org admin alone true / true / true → false / false / false 12789 → 12807 6cf54b268f58 → 65a3ee709036
'*': { modifyAllRecords } true / true / true → false / true / true 11122 → 11138 87f9b1e48f79 → e5a31ecba130
admin_full_access + member_default unchanged 12987 → 12987 identical
member_default (no entry) 6704 → 6704 identical

Collateral (H4)

  • 19 of 27 subjects are byte-identical (sha256): every subject holding no super-user wildcard, and every super-user subject with no own narrower entry and no create-less modifyAllRecords. That includes admin_full_access alone and beside member_default, organization_admin_no_bypass, and row h.
  • The 8 changed subjects: no entry is added or removed, and no bit turns false to true. Only true to false:
subject bits turned true → false
row a allowEdit ×6, allowCreate ×5, allowDelete ×5
row b allowEdit ×8, allowCreate ×5, allowDelete ×5
row c allowCreate ×16
row d allowRead, allowCreate, allowEdit, allowDelete ×1 each
row e allowRead ×1
row f allowRead ×1
row g allowCreate ×1
one-set row allowCreate, allowEdit, allowDelete ×1 each
  • One apiOperations change: row e's crm_account goes from no annotation (default-allow, which offered export) to the closure without export.
  • Enforcement untouched: permission-evaluator.ts and all of plugin-security's non-test source have 0 changed lines, so no checkObjectPermission answer changes.

Reverse verification

The fix was committed first. The mutate leg restored the producer file from base (git restore --source=49144fccc8), rebuilt core, and proved the marker live with ablation-dist-preflight (present in 2 built files). On that tree:

  • core went red on 7 of 32: the 5 new [#20136] cases and the 2 re-spelled composition cases;
  • plugin-security went red on 10 of 64: rows a, b, c, d, e, f, g and the one-set row, the spelled-out case, and row e's apiOperations column;
  • hono-server went red on 1 of 47: the new endpoint case.

Row h and every other subject stayed green. The direction is the expected one (to red). The restore used git checkout HEAD --, proven by the file's hash equalling its HEAD blob and by an empty git diff HEAD. Core was then rebuilt, with the marker proven absent from dist/.

Tests and gates, at 6527062a22

  • pnpm --filter @objectstack/core test: 53 files, 1358 passed. test:repo: 3 files, 48 passed.
  • pnpm --filter @objectstack/plugin-security test: 135 files, 2730 passed.
  • pnpm --filter @objectstack/plugin-hono-server test: 27 files, 324 passed.
  • typecheck for core, plugin-security and plugin-hono-server (tsc, the scripts/typecheck projects, check:test-typecheck over the test layer): exit 0.
  • Access-security dogfood, over the dogfood closure built at head: the 11 access-security.json checklist dogfood files plus organization-update-door.dogfood.test.ts, 12 files, 165 passed.
  • node scripts/pm/dispatch-gates.mjs --commands derived 64 commands from this diff. All 64 were run, and --ran reconciles 64 of 64 with 0 NOT-MEASURED.
    • 63 exit 0.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 unrelated packages had no dist/). After building them it exited 0.
    • check-empty-changeset --base origin/main exits 1, by design: it is the DELIBERATE CORRECTION class below, and the gate stays red until a person confirms.
  • node scripts/check-issue-citations.mjs --base 49144fccc8: exit 0, 8 citations resolve.
  • eslint --no-inline-config on the 5 changed TS files: 0 errors, 0 warnings, 5 files counted from --format json. All 5 are in the config's population (--print-config resolves each). The config enables no type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file.

Deliberate corrections of pending release notes, for confirmation

check-empty-changeset is red on these three by design. Each is one clause, and each sentence was made false by this PR:

  • .changeset/18783-server-can-option-visibility.md: "The map still differs from checkObjectPermission … an entry the super-user set itself names narrower can read as granted." now reads: "The map also differed … read as granted, which is closed in this same release (.changeset/20136-super-user-fold-per-set.md)."
  • .changeset/20134-super-user-entries-every-bit.md: "Both over-grants are left exactly as they were." now reads: "…left exactly as they were by this change, and both are closed in this same release (.changeset/20136-…)."
  • .changeset/18931-me-permissions-unrestricted-export-annotation.md: "Its CRUD bits are folded true" now reads: "Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets)". The note's population includes a bare '*': { modifyAllRecords }, which no longer reads create.

No released note was touched.

Cross-lane

For super-user subjects, two things change: the bytes /auth/me/permissions serves, and the write path's current_user.can() answer. Both change only on the cells listed above, and both move toward what the server enforces. For the seat to relay to domain:cli and domain:services at landing. The REST door, checkObjectPermission and annotateEffectiveApiOperations are untouched.

Open question

foldWildcardSuperUser is still exported, but nothing in this repository now calls it outside its own pins. Over a merged map it cannot be made to match the server, because the map has lost which set named which object. Retiring it removes a released export of @objectstack/plugin-hono-server, which is a breaking change (Clause-②: yes (narrowing)). That is a decision for the seat or the maintainer, not a rider on this fix. Recommendation: retire it in its own change, because an exported helper documented as the map's fold is how the merged reading could come back.

Acceptance notes

  • H1: every row a to e was re-measured and reproduced (row c's count differs only by fixture size, as explained above). Three rows were added: f, g, and h (control). No other super-user shape measured diverges at base.
  • H5: no released behaviour becomes refused. The write-path can() gate is PR feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079's, whose changeset is still pending. On /auth/me/permissions only map bits narrow, and the server's answer to every request is unchanged.
  • The suggested route turned KNOWN_OVER_GRANT into an empty set. This PR deletes it instead: an empty allowance ledger is a mechanism for re-admitting a divergence, and the family's assertion is exact.

Generated by Claude Code

…erged-bypass fold over a set's own narrower entry, no create on modifyAllRecords alone

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…_OVER_GRANT rows flip, rows b, d-h join the parity table

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…ding 18783, 18931 and 20134 notes the fix made false

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 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 — 24 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 84880f92243889d3e1daf9d4926d75277c9b5a94 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 84880f92243889d3e1daf9d4926d75277c9b5a94

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6527062a22436a391ac47ce36efcd8b08d4fd2ac

Scope: PR #20165 (card #20136, p1, security), 3 commits, 9 files (+258/−99): packages/core/src/security/effective-object-permissions.ts (the producer), 4 test files (core, plugin-security, 2 × plugin-hono-server), .changeset/20136-super-user-fold-per-set.md (new, @objectstack/core: patch), and one-clause DELIBERATE CORRECTIONS of pending 18783-*, 20134-*, 18931-* (numstat 1/1 each). Base = merge-base with origin/main (84880f9224) = 49144fccc8. Both trees built from source in detached worktrees (turbo 19/19 tasks each; ablation marker foldWildcardSuperUser(objects); in core dist/: base 2 files, head 0). Untouched, by diff name list: packages/spec, packages/rest, permission-evaluator.ts, every plugin-security non-test source (0 files). annotateEffectiveApiOperations body: byte-identical base→head (extracted and diffed). foldWildcardSuperUser body and signature: byte-identical base→head; still exported from core/src/security/index.ts, still re-exported by plugin-hono-server/src/current-user-endpoints.ts → index.ts; present in both built dists; it IS a released @objectstack/plugin-hono-server export (current-user-endpoints.ts:263 at tag @objectstack/plugin-hono-server@17.4.0), while core had no effective-object-permissions.ts at 17.4.0 (moved by PR #20079, unreleased). git merge-tree --write-tree origin/main 6527062a22: clean, tree c89c0338b3; 0 files in common between this PR and main's 3 commits since base.

① Derived judgments

  • Parity, the class-closure bar (my own harness, not the dev's pin): map can() = real formula CEL current_user.can() over toEvalPermissions(svc.getEffectiveObjectPermissions()) from the real SecurityPlugin (fake engine, resolver spied as the pin does), against PermissionEvaluator.checkObjectPermission with { isPrivate }; 61 registered objects (every schema exported by @objectstack/platform-objects and plugin-security objects, + 4 app objects: public / apiMethods / private / apiEnabled:false) × 10 verbs = 610 cells per subject; 42 subjects = the head table's 27 + 15 shapes of mine. Head: 0 over-granted and 0 under-granted in every one of the 42 subjects. Bar met; no subject fails. Base over-grants (→ head 0): row a 38, row b 44, row c 32, row d 8, row e 2 (+1 offered-but-refused export), row f 1, row g 2, row h 0→0, one-set row 7 — the dev's table reproduced cell for cell (row c = create+import on the 16 unclamped objects). My extra shapes, all non-zero at base and 0 at head: two super-user sets naming the same object differently 4; super-read set naming narrower beside a modify-all-only set 32; viewAllRecords/modifyAllRecords split across two sets 32; super-user set naming guarded managed objects (sys_user, sys_member) 3; super-read set naming a private object narrower 1; own entry carrying entry-level bypass bits 9; walled org admin beside a plain read-edit wildcard 20; one-set row + member_default 7; walled + wall-less org admin together 38; walled org admin + viewer_readonly 44; own entry with explicit false bits 7; modify-all-only set naming read-only beside a plain create wildcard 7. Under-granted: 0 at base and 0 at head for all 42 (clamp-forgiven cells constant per subject). Engine-registered resolver answered byte-equal to the service member for every subject.
  • Reach, write path (real ObjectQL + real SqlDriver sqlite + real SecurityPlugin init/start, its own resolver registered on the engine, sets through the plugin's resolvePermissionSetsForContext; crm_case.stage options gated on current_user.can(…)): insert — row a can('sys_position','edit') and can('sys_position','create'): ADMITTED at base → REFUSED VALIDATION_FAILED / stage:invalid_option at head; row b the same two plus can('sys_user','edit'); checkObjectPermission false on each. by-id update (a real write, all 25 cells): exactly the dev's 7 flips (rows a ×2, b ×3, c ×2 = sys_position create and crm_account create) ADMITTED → REFUSED invalid_option; the other 18 unchanged (controls admin_full_access+member_default ADMITTED both trees; member_default PERMISSION_DENIED both trees, no crm_case grant; every server-granted cell ADMITTED both trees). Row c's own crm_case insert is PERMISSION_DENIED at both (bare modifyAllRecords grants no create), so its flip shows on update and on validate() preview (VALID → INVALID stage/invalid_option). can() CEL defaultValue (objectql: a formula field or a CEL defaultValue that calls current_user.can() gets no permission data — the formula reads a silent null on every read, the default is left unset with a warn (applyFormulaPlan, applyFieldDefaults) #20082's path): row a/b can('sys_position','edit'|'create') stored true → false; crm_account edit/read true → true; admin control all true → true; one-set row crm_account edit true → false.
  • Reach, GET /api/v1/auth/me/permissions (real registerCurrentUserEndpoints on Hono, same 61 schemas): row a objects.sys_position create/edit/delete true/true/true → false/false/false, objects sha 7d78b7069a14 → 7c41ba445efa; row b 6cf54b268f58 → 65a3ee709036, sys_user.allowEdit true → false; row c sys_position.allowCreate true → false, edit/delete stay true, 87f9b1e48f79 → e5a31ecba130 — the dev's three objects hashes exactly (byte totals 13185→13201 / 12787→12805 / 11120→11136 vs the dev's 13187→13203 etc.: the 2-byte delta is my fixture's shorter session email). Controls admin_full_access+member_default (12985 B, 9357b1eee521) and member_default (6702 B, 339c227a2c37): response byte-identical. 22/42 subjects byte-identical; endpoint objects == builder map bytes for all 42 in both trees.
  • No server decision moved: permission-evaluator.ts 0 changed lines; plugin-security non-test source 0 changed files. Across all 42 subjects: entries added 0, removed 0, bits false→true 0; true→false only: row a allowEdit×6 allowCreate×5 allowDelete×5, row b 8/5/5, row c allowCreate×16, row d read/create/edit/delete ×1 each, row e allowRead×1, row f allowRead×1, row g allowCreate×1, one-set row create/edit/delete ×1 each. apiOperations column (core: /auth/me/permissions apiOperations ignores enable.apiEnabled: false — an object the REST layer answers 404 for is annotated with the full operation list (annotateEffectiveApiOperations → resolveEffectiveApiMethods) #20135's pin, re-measured with canServeApiOperation + svc.canExport): 0 offered-but-refused and 0 served-but-hidden at head for all 42; row e 1→0 (crm_account: no annotation → closure without export); no other annotation moves. Every subject with no super-user wildcard (8) is byte-identical; so are 14 super-user subjects (admin_full_access alone / + member_default / + wall-less / row h, super-read alone, export-carrying shapes, …).
  • The pins discriminate (head worktree): producer restored from the base blob da81b3192b… (git checkout 49144fccc8 --), core rebuilt, marker present in 2 dist files → core 7/32 red (5 [#20136] cases + the 2 re-spelled composition cases), plugin-security 10/64 red (rows a, b, c, d, e, f, g, the one-set row, the spelled-out case, row e's apiOperations column), hono 1/47 red (the new endpoint case; 47 = 5 + 29 + 13 over the three hono files). Restored by blob git checkout HEAD --: hash-object = HEAD blob d3208e7cf2…, git diff HEAD empty, core rebuilt, marker absent (0 files), pins 32/32, 64/64, 47/47 green.
  • Clause-② / semver under AGENTS.md: Clause-②: no and @objectstack/core: patch are right. No spec key, route, export, config key or response key is added or removed; entries are neither added nor removed; only true bits turn false toward what checkObjectPermission enforces (a bug fix in a released package → patch; same class and level as the landed core: /auth/me/permissions apiOperations ignores enable.apiEnabled: false — an object the REST layer answers 404 for is annotated with the full operation list (annotateEffectiveApiOperations → resolveEffectiveApiMethods) #20135). The write-path refusals belong to PR feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079's can() option gating, whose changeset 18783-* is still pending in .changeset/ (last release @objectstack/*@17.4.0, chore: version packages 7e6337007f, 2026-09-09, predates PR feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079's merge 2026-09-25); objectql: a formula field or a CEL defaultValue that calls current_user.can() gets no permission data — the formula reads a silent null on every read, the default is left unset with a warn (applyFormulaPlan, applyFieldDefaults) #20082's 20082-formula-default-can.md is pending too. So no released behaviour becomes refused; the released surface that moves is the /auth/me/permissions objects values for the listed super-user cells, and the changeset states that change explicitly. foldWildcardSuperUser kept exported with an unchanged body means no export removal → no (narrowing); its rewritten docblock travels into a .d.ts comment, shipped bytes but not the accept set. Gates, run from the head worktree: GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event event.json → exit 0: "✓ This diff introduces no major bump. ✓ LEVEL AXIS: this PR declares clause-② no, so no package here is declared to have grown a published surface. · declaration line: Clause-②: no · direction arm: none declared". node scripts/check-adr-0087-registration.mjs --base origin/main → exit 0: "✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (4 non-breaking changeset(s) seen)."
  • The three DELIBERATE CORRECTIONS, old sentence read literally at base and head, new at head; every other line byte-identical (numstat 1/1 per file): 18783-* old "The map still differs from PermissionEvaluator.checkObjectPermission for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted." — base TRUE (rows a/b/f: 38/44/1), head FALSE (0 over everywhere) → made false by this fix; new "The map also differed … read as granted, which is closed in this same release (.changeset/20136-super-user-fold-per-set.md)" — TRUE at head (both notes pending in the same lockstep release). 20134-* old "Both over-grants are left exactly as they were." — base TRUE, head FALSE; new "…left exactly as they were by this change, and both are closed in this same release (.changeset/20136-…)" — TRUE. 18931-* old "Its CRUD bits are folded true" — base TRUE (merged fold), head FALSE for the note's own population (a bare '*': { modifyAllRecords } no longer reads create: row c); new "Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets)" — TRUE (both built-in admin wildcards carry all four; seeded entries take them per set). node scripts/check-empty-changeset.mjs --base origin/main → exit 1, red on exactly .changeset/18783-server-can-option-visibility.md, .changeset/18931-me-permissions-unrestricted-export-annotation.md, .changeset/20134-super-user-entries-every-bit.md ("present on the merge base and CHANGED by this PR"; rule 1 green: "4 declaring changeset(s) added"). Other pending notes read at head: 20135-* ("coverage passes that run first have already put each set's '*' on exactly the entries that set reaches" — now exactly true), 20083-*, 18990-*, and the rest of 18783-* — none reads FALSE. Stale-but-true naming: 18990-* and 18783-* still call foldWildcardSuperUser "the fold"/"the four folds"; the helper still asks wildcardGrantsSuperRead and is still exported/re-exported, so the sentences hold literally. In 20134-*, the two lead sentences "The fold still folds…" / "It also still pulls…" describe the exported helper, not the head map; the heading "unchanged here" and the appended clause scope them to core: effective-map super-user entries never carry transfer (or a super-read wildcard's plain bits) — current_user.can(obj, 'transfer') is false for admin_full_access where enforcement answers true via modifyAllRecords #20134's change — acceptable, flagged in ③.
  • New changeset 20136-*, PR body, rewritten docblock, sentence by sentence against head: no FALSE sentence found. Verified in particular: the 38/44 cell decomposition (5 objects × 7 write verbs + sys_organization edit ×3; + sys_user, sys_api_key edit ×3 each without member_default); "{} read as readable and writable" (row d incl. read); "export-only entry read as readable and exportable, so apiOperations offered export" (row e); "create + import on every object the clamp does not cover" (16×2); "No entry is added or removed and no bit turns from false to true"; "byte-identical for every subject whose super-user sets name no object narrower and grant allowCreate wherever they carry modifyAllRecords … and for every subject holding no super-user wildcard" (holds on all 42); "a can() default reads false" (measured); PR body "19 of 27 byte-identical", "7 of 32 / 10 of 64 / 1 of 47", "rows a, b and d reproduce 38, 44, 8", "row c 32 here / 36 on the card"; docblock "NARROWER on transfer and on a super-user wildcard's own plain bits, which it never sets" (body sets only read/edit/create/delete), "released @objectstack/plugin-hono-server export of the same name" (true at tag 17.4.0), "⛔ NOT a step of buildEffectiveObjectPermissions" (the call is removed; only foldSuperUserWildcardGrants runs). One nuance, not a falsity: the body's write-path row for '*': { modifyAllRecords } says "ADMITTED → refused" without naming the write; on my engine that subject's crm_case insert is PERMISSION_DENIED at both trees and the flip is on update and validate().
  • Scope and merge: as in Scope above; the region predecessors (security: the effective object-permission map omits objects covered only by a plain '*' grant, so current_user.can() answers false where enforcement answers true (wall-less org admins) #20083/PR fix(core): the effective object-permission map covers a plain * grant, so current_user.can() agrees with checkObjectPermission for a wall-less org admin (#20083) #20132, core: effective-map super-user entries never carry transfer (or a super-read wildcard's plain bits) — current_user.can(obj, 'transfer') is false for admin_full_access where enforcement answers true via modifyAllRecords #20134/PR fix(core): effective-map entries reached through a super-user * carry every bit the server grants, so can(object, 'transfer') agrees with checkObjectPermission (#20134) #20145, core: /auth/me/permissions apiOperations ignores enable.apiEnabled: false — an object the REST layer answers 404 for is annotated with the full operation list (annotateEffectiveApiOperations → resolveEffectiveApiMethods) #20135/PR fix(core): apiOperations offers only what the REST door serves — nothing on apiEnabled: false, export only where the export door admits it (#20135) #20151) are all in the base; the apiOperations column this PR extends is fix(core): apiOperations offers only what the REST door serves — nothing on apiEnabled: false, export only where the export door admits it (#20135) #20151's and is untouched in source.
  • CI at head 6527062a22 (read last, all 34 check runs completed): 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 1 failure: Check Changeset — job log: ##[error].changeset/18783-server-can-option-visibility.md exists on the merge base and was not added by this PR, so changing or deleting it silently replaces somebody else's release note (#17712). … DELIBERATE CORRECTION -- … Remedy: do NOT restore it -- say so on the PR and get it confirmed, the same line for 18931-* and 20134-*, then ##[error]Process completed with exit code 1. — the expected by-design red on exactly the three corrected names. Required contexts (repo ruleset 12119582 required_status_checks): TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard — all seven success; Check Changeset is not a required context. No other failure.

② Semver level

@objectstack/core: patch, Clause-②: no, no direction arm — consistent with the declaration in the PR body, the changeset and the claim; agrees with the level axis of check-changeset-no-major and with check-adr-0087-registration (no breaking changeset, no ADR-0087 disposition due). No other package publishes a change (test-only edits in plugin-security and plugin-hono-server; the three corrected notes keep their own frontmatter).

③ Boundary flags

  • Open question → seat amendment B (keep foldWildcardSuperUser exported, body unchanged, docblock rewritten): implemented as amended and verified (body diff empty, export chain and dists intact). Retirement (option A) is a removal of a released @objectstack/plugin-hono-server export → Clause-②: yes (narrowing) with an ADR-0087 disposition, a maintainer decision; stays open, correctly not a rider here.
  • DELIBERATE CORRECTION ×3: confirmed by this record (notes named, each rewritten sentence judged above); Check Changeset stays red by design — landing under the SKILL.md three-condition path.
  • Deviation, KNOWN_OVER_GRANT deleted rather than emptied: accepted; the mutation lap shows the exact table still names its rows (7/10/1 red).
  • Wording, non-blocking: 20134-* lead sentences "The fold still folds… / It also still pulls…" now describe the standalone helper, not the map; 18990-*/18783-* still name foldWildcardSuperUser as the map's fold — true of the helper, stale as a description of the map. Not made false by this PR; a later docs-only tidy, never a rider.
  • Cross-lane relay (as the PR body asks): for super-user subjects with an own narrower entry or a create-less modifyAllRecords, /auth/me/permissions bytes and the write-path can() answer move toward enforcement on the listed cells; nothing else moves. For the seat to relay to domain:cli and domain:services at landing.
  • Dev's out-of-scope note (clamp does not narrow allowTransfer on guarded objects): read-only, unmeasured, outside this family's parity (evaluator and map agree there) — for the seat's filing gate, not this PR.

Implemented-by: claude/issue-20136-super-user-fold-over-grant
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 04:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit e2c4e12 Sep 27, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20136-super-user-fold-over-grant branch September 27, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants