Skip to content

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

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20134-super-user-seed-bits
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20134-super-user-seed-bits

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20134

Clause-②: yes (widening)

The effective object-permission map (the objects slot of GET /auth/me/permissions, and what ISecurityService.getEffectiveObjectPermissions hands to current_user.can()) now carries every bit a super-user '*' grants on the server. A super-user wildcard is one carrying viewAllRecords or modifyAllRecords. Before this change, its entries diverged from PermissionEvaluator.checkObjectPermission in three places, all in the refuse direction:

The fix is at the producer, @objectstack/core buildEffectiveObjectPermissions, as triage directed (「seed and fold every bit checkObjectPermission reads」). checkObjectPermission, formula's can(), annotateEffectiveApiOperations and every route and plugin source are untouched.

What changes

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

  • seedSuperUserRestrictedObjects places an entry for every registered object the merged map lacks, whenever the merged '*' carries a super-user bit. It used to skip an unrestricted object whose export stays allowed, because that object needs no apiOperations. The seed no longer resolves the operation set at all. Its signature and its {allow*: false} initial shape are unchanged.

  • foldSuperUserWildcardGrants is new and module-private. It runs right after foldWildcardSuperUser and before the managed-write clamp. For each set whose '*' is a super-user wildcard, it walks every entry that set does not name. On each such entry it sets every grant bit the spec's objectPermissionGrants says that wildcard grants: the one fold checkObjectPermission itself asks. This is resolveObjectPermission's per-set reading:

    • a set that names an object keeps its explicit entry as its whole answer for that object;
    • a private object is covered, as a super-user wildcard covers it on the server;
    • only true bits are set.

    For a super-user wildcard the read cell is always true, so its export cell is exactly its own allowExport, the grant half the evaluator's export conjunction reads. The bits are derived, not listed.

  • foldWildcardSuperUser has an unchanged body. Its docblock now says what the merged reading cannot carry, and names its two known over-grants (below). The old line "exactly as broad as real enforcement — never broader" was false for those cells.

No exported name or type changes. Both helpers keep their signatures, and the new step is module-private.

Measurement

The map answer is the real formula current_user.can() over toEvalPermissions(map). It is compared with PermissionEvaluator.checkObjectPermission on all 10 verbs the vocabulary accepts. under means the map refuses a verb the evaluator grants, and over the reverse. As in the #20083 pin, create/edit/delete refused on a guarded managed object is the managed-write clamp and is not counted.

Unit fixture: 63 registered objects × 10 verbs. The objects are 52 from @objectstack/platform-objects, 7 plugin-security objects, and 4 app objects: public, apiMethods-restricted, private, and apiEnabled: false. Base is a08e059c61, with core's dist/ built from base source. Head is 071e3f49fb.

subject (resolved sets) entries under over map bytes
admin_full_access 64 → 64 63 → 0 0 → 0 changed
admin_full_access + member_default 66 → 66 63 → 0 0 → 0 changed
admin_full_access + organization_admin_no_bypass + member_default 66 → 66 63 → 0 0 → 0 changed
walled organization_admin + member_default 66 → 66 30 → 0 38 → 38 (same cells) changed
'*': { modifyAllRecords } 64 → 64 63 → 0 36 → 36 (same cells) changed
'*': { modifyAllRecords, allowCreate, allowRead } 64 → 64 63 → 0 0 → 0 changed
'*': { viewAllRecords, allowEdit } 64 → 64 54 → 0 0 → 0 changed
'*': { viewAllRecords, allowEdit, allowTransfer, allowDelete } 64 → 64 153 → 0 0 → 0 changed
'*': { viewAllRecords } 64 → 64 0 → 0 0 → 0 identical
'*': admin bits + allowExport 53 → 64 214 → 0 0 → 0 changed
'*': { viewAllRecords, allowExport } 53 → 64 74 → 0 0 → 0 changed
'*': admin bits + allowExport, + member_default 56 → 66 206 → 0 0 → 0 changed
explicit crm_account: read beside another set's super-user '*' 54 → 64 136 → 0 0 → 0 changed
ONE set: super-user '*' and a narrower explicit crm_account 64 → 64 62 → 0 7 → 7 (same cells) changed
super-read '*' beside a plain '*' 64 → 64 0 → 0 0 → 0 identical
admin_full_access beside a plain export-only '*' 53 → 64 161 → 0 0 → 0 changed
member_default 31 → 31 0 → 0 0 → 0 identical
organization_admin_no_bypass + member_default 64 → 64 0 → 0 0 → 0 identical
viewer_readonly + member_default 64 → 64 0 → 0 0 → 0 identical
the four #20083 plain-wildcard shapes — 0 → 0 0 → 0 identical
  • No narrowing, measured entry by entry, base against head over all 23 subjects. No entry is removed, no true bit turns false, and no apiOperations list loses or gains an operation. The head adds 53 entries (the allowExport shapes' unrestricted objects) and true bits: allowTransfer × 688, allowExport × 212, allowEdit × 42, allowDelete × 18.
  • The head adds ZERO over-granted cells in any subject. The three pre-existing over-grant sets are the same cells at base and head.

Public door on a booted showcase. bootStack(showcase) in its default composition, with the seeded platform admin (positions platform_admin, everyone; sets showcase_member_default + admin_full_access + member_default). The answer read is the response bytes of GET /auth/me/permissions itself. The comparison covers 78 registered objects × 10 verbs against checkObjectPermission over the same resolved sets.

base head
under 78 (all transfer) 0
over 0 0
entries / objects bytes 81 / 16695 81 / 17385
showcase_account allowTransfer: false, can(…, 'transfer') false; server true allowTransfer: true, can() true

That is the card's 63 fixture cells and 78 showcase cells, reproduced exactly.

The base leg of this table and of the write-path table is this head with both changes ablated and core's dist/ rebuilt. On the fixture, that leg's maps are byte-identical (sha256) to true base a08e059c61's, with the same under and over counts, in all 23 subjects.

Write path. The real ObjectQL engine, with the real SecurityPlugin member registered as its resolver. crm_case.stage has options gated on current_user.can('crm_account', …), and a boolean field has CEL defaultValue current_user.can('crm_account', 'transfer').

subject option transfer option edit option read defaultValue
admin_full_access + member_default refused VALIDATION_FAILED/invalid_option → admitted admitted → admitted admitted → admitted false → true
walled organization_admin + member_default refused → admitted admitted → admitted admitted → admitted false → true
'*': { viewAllRecords, allowEdit } refused → refused (server refuses) refused → admitted admitted → admitted false → false
'*': admin bits + allowExport refused → admitted refused → admitted refused → admitted — → true
member_default refused → refused refused → refused refused → refused —
organization_admin_no_bypass + member_default refused → refused admitted → admitted admitted → admitted false → false
viewer_readonly + member_default refused → refused refused → refused admitted → admitted false → false

Every head cell agrees with checkObjectPermission, and every control cell is unchanged.

The #18990 ruling and the seed's skip

The pins this PR inverts in plugin-hono-server's effective-api-operations.test.ts quote the #18990 ruling (batch #159 item 1, letter A). The ruling's headline is 「/auth/me/permissions owes every object a principal can reach an explicit entry」. The carve-out the pins quote, 「skip only an unrestricted object whose export stays allowed」, is written about annotateEffectiveApiOperations attaching apiOperations.

This PR keeps that carve-out exactly. An entry seeded for an unrestricted, export-allowed object carries no apiOperations, and annotateEffectiveApiOperations is untouched. What changes is that the entry now exists, which is the ruling's headline. current_user.can() reads the entry and reads an absent one as 「no grant」, a reader the ruling predates.

Ruled: Q2 → A (seat amendment 5831894689 on #20134). The #18990 ruling line (5729478681), quoted verbatim:

Ruling: batch #159 item 1 · letter A (/auth/me/permissions owes every object a principal can reach an explicit entry: the seed runs for a wildcard-only viewAllRecords principal too — allowRead folded true, the write bits false, apiOperations annotated by the same predicate as #18931; PR #18984's toBeUndefined pin flips on purpose) · maintainer 「同意」 2026-09-18T11:42Z

The entry is owed. The #18931 skip predicate governs the apiOperations annotation, which this PR keeps byte-identical (0 apiOperations lost or gained across 23 subjects). Seeding the entry for an unrestricted, export-allowed object therefore carries out the ruling's letter; it does not reopen it. The three effective-api-operations.test.ts seed pins that quoted the annotate skip as a seed skip are inverted on purpose, and the amendment adds that file to the claimed surface.

Known divergence, pre-registered and unchanged (#20136)

The fold is broader than the evaluator in two places. Both are OVER-grants and belong to #20136, the family's over-grant card, which remains open. This PR leaves both byte-identical.

KNOWN_OVER_GRANT in the parity pin lists both, cell for cell, and the pin asserts the observed set EXACTLY. That is the flip trigger: when #20136 lands, those rows go red and their entries are deleted, never widened.

For domain:cli (the route) and domain:services (plugin-security)

No code in plugin-hono-server or plugin-security changes, only their pins. What their readers see:

  • GET /auth/me/permissions → objects. For a subject holding a super-user wildcard, entries gain true bits (allowTransfer, and the wildcard's own plain and export bits). Where that subject's wildcards also grant allowExport, the map gains an entry for each registered unrestricted object that had none, and that entry carries no apiOperations. Nothing is removed, and no apiOperations list changes. Shape, keys and route are unchanged.
  • ISecurityService.getEffectiveObjectPermissions. The same map, since it is the same function. On the write path, a can()-gated option or defaultValue is admitted for these subjects wherever the server grants.
  • Byte-identical for every subject holding no super-user wildcard (the plain-wildcard and member rows above).

There is no non-test requester of /auth/me/permissions in this repo. objectui's can() is UNMEASURED: the sibling repo is not in this container.

Clause-②

DELIBERATE CORRECTION: three pending changesets

Each correction changes ONE clause or bullet of a pending release note that this PR makes false or inexact, following the #20083 precedent. Every other line of each file is byte-identical.

1. .changeset/18783-server-can-option-visibility.md, line 37 (one clause)

The pending current_user.can() write-path changeset ends its Plain-wildcard coverage, closed in this release. paragraph (the paragraph #20132 corrected) with a clause this PR makes FALSE.

  • Before: 「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, and an entry reached through a super-user wildcard carries no transfer.」
  • After: 「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. Its missing transfer is closed in this same release (.changeset/20134-super-user-entries-every-bit.md).」

Byte identity. git diff --numstat gives 1 1 on the file, 39 lines before and after. With line 37 removed, the before and after files are identical (diff exit 0). Line 37 is identical up to and including 「…names narrower can read as granted」, and only its tail changes. The blob goes from f357292a to 29a6ba2e.

The new sentences are TRUE at this head (c63f61522c; its code and test blobs are identical to 071e3f49fb, where the measurements above were taken):

2. .changeset/18931-me-permissions-unrestricted-export-annotation.md, line 13 (one bullet)

Why the old bullet is FALSE at this head. The seed skips no registered object for a super-user wildcard any more. Also, the per-set super-user fold puts allowExport on a seeded entry whenever a set's super-user wildcard grants it, so 「a seeded entry carries no allowExport of its own」 no longer holds.

Why the new bullet is TRUE.

  • seedSuperUserRestrictedObjects gives an entry to every registered object the merged map lacks, whenever the merged '*' carries a super-user bit. That is what 「…that the merge left without one」 says. The seat's suggested text read 「places an entry for every registered object a super-user wildcard reaches」; an object the merge already carries keeps its own entry, so the clause is narrowed to what the seed actually writes.
  • annotateEffectiveApiOperations is untouched, and it is the only pass that writes apiOperations. It skips exactly an object that is unrestricted and whose export bit is true.
  • Measured: 0 apiOperations lost or gained across 23 fixture subjects and on the booted showcase.

Byte identity. git diff --numstat gives 1 1, 16 lines before and after. With line 13 removed, the files are identical (diff exit 0). The blob goes from 38bfb50f to 6eac3716.

3. .changeset/18990-viewall-only-permissions-seed.md, line 12 (one bullet)

Why. 「is still skipped」 was true of apiOperations and is false of the entry, which the seed now places. The corrected text states both halves. The ruling's carve-out governs apiOperations only, which this PR keeps byte-identical.

Byte identity. git diff --numstat gives 1 1, 14 lines before and after. With line 12 removed, the files are identical (diff exit 0). The blob goes from 3ed4a3fe to 261e412f.

Check Changeset stays red on this PR by design. check-empty-changeset names exactly these three files, because it treats an edit to another card's pending changeset as a foreign-changeset edit. pr-automation.yml runs it on pull_request only, never on merge_group, as with #20083. Three named files add no new class of red.

Changeset gates at c63f61522c, run against merge base a08e059c61. check-changeset-no-major read this body as its --event payload.

$ node scripts/check-empty-changeset.mjs --base a08e059c61                 -> exit 1 (expected)
✓ No empty-frontmatter changeset introduced by this diff (4 declaring changeset(s) added).
This PR changes a changeset it did not add:
   .changeset/18783-server-can-option-visibility.md
     present on the merge base and CHANGED by this PR -- this is somebody else's release note
   .changeset/18931-me-permissions-unrestricted-export-annotation.md
     present on the merge base and CHANGED by this PR -- this is somebody else's release note
   .changeset/18990-viewall-only-permissions-seed.md
     present on the merge base and CHANGED by this PR -- this is somebody else's release note

$ node scripts/check-changeset-no-major.mjs --base a08e059c61 --event (this body)   -> exit 0
✓ This diff introduces no `major` bump.
✓ LEVEL AXIS: this PR declares clause-② `yes (widening)`, and no package whose `packages/**/src/**` it moves is graded `patch`.

$ node scripts/check-adr-0087-registration.mjs --base a08e059c61          -> exit 0
✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (4 non-breaking changeset(s) seen).

$ node scripts/check-issue-citations.mjs --base a08e059c61                -> exit 0
citations judged: 11 across 1 file(s)
✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference).

Tests

All at 071e3f49fb, dist/ of the closure rebuilt, each run under the shared verify lock. The head is now c63f61522c, which adds only the three changeset corrections above; every code and test blob is identical to 071e3f49fb:

  • pnpm --filter @objectstack/core test: 53 files / 1346 passed. typecheck exit 0 (check:test-typecheck OK, 4 pinned signatures held).
  • pnpm --filter @objectstack/plugin-security test: 135 files / 2697 passed. typecheck exit 0.
  • pnpm --filter @objectstack/plugin-hono-server test: 27 files / 317 passed. typecheck exit 0.
  • Access-security dogfood (the 11 files the checklist area names, plus every dogfood file reading the effective map or current_user.can, plus 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's permission set): 18 files / 261 passed, 1 skipped.
    • shared-showcase: showcase-anonymous-deny-surfaces, showcase-permission-zoo, showcase-private-owd, two-doors-permission: 4 / 66.
    • isolated: me-apps-and-everyone-baseline, owner-anchor-and-bulk-writes, showcase-client-liaison-fixtures, showcase-crud-persona-matrix, showcase-fls-read-mask-strip, showcase-scope-depth-fallback, showcase-scope-depth-write, showcase-scope-depth, organization-update-door, showcase-permission-projection, showcase-permission-seeding, comments-permission-matrix, attachments-permission-matrix, authz-conformance: 14 / 195 + 1 skipped.

New and changed pins:

Ablation, from the committed head through scripts/ablation-replace.mjs, two nested WRAP legs:

  • The per-set call was replaced by a runtime marker: anchor 1 → 0, blob 0955a452 → c8367daf.
  • The seed's old skip was re-inserted with its own marker: anchor 1 → 0, blob c8367daf → eb95e485.
  • ablation-dist-preflight found both markers in 2 core dist/ files.
  • Observed direction: red.
    • plugin-security 11 failed / 20 passed: every super-user row and both spelled-out cases. The 7 plain rows stayed green.
    • core 6 failed / 14 passed.
    • hono 4 failed / 36 passed.
  • Restore: blob == HEAD and git diff HEAD empty on both legs. After the rebuild, --absent passes for both markers over 14 dist files, the whole tree is clean, and the pins are green again (31 / 20 / 40).
  • The first attempt was refused by the tool before any command ran: the inner replacement contained its own anchor. Nothing was measured on it, and both legs restored and proved clean.

Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derives 64 commands at 071e3f49fb; all 64 exit 0.

  • pnpm check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3), because 8 unrelated packages had no dist/. That was NOT MEASURED, not red. After building them it exits 0 (105 entries / 67 packages / 660 CJS files).
  • --ran reads 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
  • node scripts/check-issue-citations.mjs --base a08e059c61 exits 0, with 11 citations resolving.
  • Lint, as a proven narrowing at 071e3f49fb:
    1. eslint's own config admits all 5 changed .ts files (isPathIgnored false).
    2. --format json counts 5 files, 0 errors, 0 warnings.
    3. The config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict.
      The repo-wide pnpm lint is CI's.

Acceptance notes

  • The three pending release notes this PR made false or inexact (18783, 18931, 18990) are corrected here, one clause or bullet each (see DELIBERATE CORRECTION above).
  • transfer on guarded managed objects. The managed-write clamp narrows create, edit and delete, not transfer. The map therefore answers transfer as checkObjectPermission does on a guarded object too. The 63-object fixture registry holds 45 guarded objects: 44 from @objectstack/platform-objects, plus plugin-security's engine-owned sys_audience_binding_suggestion. An earlier count of 44 read the platform objects alone. Of the 45, only sys_metadata has an owner field. Whether its engine-owned guard refuses the owner update that a transfer rides is UNMEASURED here.
  • Branch base. The branch sits on a08e059c61. origin/main has moved one commit since (f09d4122bc, driver-sql / driver-turso only), which touches none of this diff's packages.

…very bit checkObjectPermission grants

The super-user seed now places an entry for every registered object a
super-user wildcard reaches (it skipped an unrestricted object whose export
stays allowed), and a per-set pass puts each set's super-user wildcard grants
on the entries that set does not name: transfer through modifyAllRecords, and
the wildcard's own plain bits. The merged fold is unchanged.

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

The parity table gains one row per super-user wildcard shape and holds each at
0 under-granted cells; the fold's two known over-grants are pre-registered cell
for cell with a flip trigger. The seed pins that asserted no entry for an
unrestricted, export-allowed object now assert an entry with no apiOperations.

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 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 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 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 949e99bed9352d49a82aa186fffef86468a9d533 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 949e99bed9352d49a82aa186fffef86468a9d533

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

…branch makes false

The pending can() write-path changeset said an entry reached through a
super-user wildcard carries no transfer. This branch closes that half, so the
clause now points at the changeset that closes it; every other line is
unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d1d1203a55445b15414bfa84c31bcfbf559aaed1

Scope: 7 files (+368/−55) on merge base a08e059c61:

  • packages/core/src/security/effective-object-permissions.ts, the only non-test source;
  • its test, plus plugin-security's get-effective-object-permissions.test.ts and plugin-hono-server's effective-api-operations.test.ts and current-user-endpoints-effective-objects.test.ts;
  • the new .changeset/20134-super-user-entries-every-bit.md;
  • a one-line DELIBERATE CORRECTION of .changeset/18783-server-can-option-visibility.md.

No governed surface and no packages/spec/src is touched. Measured in detached worktrees at head and base, each with its closure built.

① Derived judgments

② Semver level

Clause-②: yes (widening), @objectstack/core minor: RIGHT.

  • The map answers widen to match enforcement, and can()-gated writes are admitted.
  • There is no new export or type member, and nothing narrows.
  • check-changeset-no-major --event <PR body>: "✓ This diff introduces no major bump." and "✓ LEVEL AXIS: … yes (widening) …" (exit 0).
  • check-adr-0087-registration: "✓ … no declared-breaking changeset (2 non-breaking changeset(s) seen)." (exit 0).

③ Boundary flags

  • DELIBERATE CORRECTION confirmed on d1d1203a55: .changeset/18783-server-can-option-visibility.md, line 37 only; the other 38 lines are byte-identical.
    • The base clause's transfer half ("… and an entry reached through a super-user wildcard carries no transfer.") is FALSE at head.
    • The head sentences ("… names narrower can read as granted." and "Its missing transfer is closed in this same release …") are TRUE at head.
  • Two more pending release notes are made FALSE or misleading by this head and are NOT corrected:
    • .changeset/18931-me-permissions-unrestricted-export-annotation.md line 13: "The seed now applies annotate's own predicate … skip only an object that is unrestricted and keeps export. A seeded entry carries no allowExport of its own and foldWildcardSuperUser does not add one …". This is FALSE at head: the seed skips no registered object, and the per-set step puts allowExport on seeded entries.
    • .changeset/18990-viewall-only-permissions-seed.md line 12: "an unrestricted object whose export stays allowed is still skipped". This is true of apiOperations and false of the entry, which is now seeded.
  • Out-of-scope findings for security: the effective permission map folds a super-user '*' over the same set's narrower explicit entries — a walled org admin gets can('sys_position','edit') true where enforcement refuses #20136's enumeration, the same at both trees:
    • a walled organization_admin WITHOUT member_default over-grants 44 cells (also edit on sys_user and sys_api_key);
    • an EMPTY explicit entry {} inside a super-user set over-grants read as well (8 cells).

Implemented-by: claude/issue-20134-super-user-seed-bits
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: FAIL

Must-change (prose only; every code and test judgment above PASSES and needs no re-measurement on a head that changes .changeset/* only):

  1. A DELIBERATE CORRECTION of .changeset/18931-me-permissions-unrestricted-export-annotation.md line 13, one bullet, every other line byte-identical.
  2. The same for .changeset/18990-viewall-only-permissions-seed.md line 12: narrow "is still skipped" to the apiOperations statement.
  3. (nit) PR body: "44 guarded objects" → 45.

…makes inexact

The unrestricted-export-annotation note said the seed skips an unrestricted,
export-allowed object and that no pass adds allowExport to a seeded entry; the
viewAll-only seed note said that object is still skipped. The seed now places
the entry and only the apiOperations annotation skips it. One bullet each;
every other line is unchanged.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…without an entry

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c63f61522c274bc29fb8dc9fb330fd5bc517fe88

Scope: a DELTA review. It supersedes FAIL 5832348775 (on d1d1203a55). Two prose-only commits (b60ed0d00f, c63f61522c) sit on d1d1203a55, and the PR body was edited.

① Derived judgments

  • Blob identity, measured by the reviewer: git diff --stat d1d1203a55 c63f61522c touches exactly .changeset/18931-* and .changeset/18990-*, 1 line each. Every code and test blob, and the 20134-* and 18783-* changesets, are identical. Every reading of 5832348775 therefore stands for this head:
    • parity over 32 subjects;
    • the showcase door, 78 → 0;
    • the write path;
    • the ablation, 11/20, 6/14 and 4/31;
    • the .d.ts surface and the scope.
  • The dev's wording departure on 18931 is CORRECT and more exact. The added words are "… that the merge left without one". The seed writes only where objects[name] is absent, so an object the merge already carries keeps its own entry.
  • Gates:
    • check-empty-changeset exits 1 as expected, naming exactly the three corrected files.
    • check-changeset-no-major --event <new body> exits 0: "✓ This diff introduces no major bump." and "✓ LEVEL AXIS: … yes (widening) …".
    • check-adr-0087-registration exits 0.
  • merge-tree against origin/main 949e99bed9 is clean.
  • CI at this head: 41 runs, 34 success, 5 skipped, 2 failure, 0 pending. All seven required contexts are success. Both failures are Check Changeset, naming exactly the three corrected files: the expected red.
  • PR body: no NEW false sentence, and the "45 guarded objects" nit is fixed. There is one stale bookkeeping line ("origin/main has moved one commit"; it is now two). It is non-blocking.

② Semver level

Clause-②: yes (widening), @objectstack/core minor: RIGHT, unchanged from 5832348775.

③ Boundary flags

DELIBERATE CORRECTION confirmed on c63f61522c, for three pending release notes. Each changes one clause or bullet, and every other line is byte-identical.

  1. .changeset/18783-server-can-option-visibility.md line 37.
    • Base: "… and an entry reached through a super-user wildcard carries no transfer." FALSE at head.
    • Head: "… names narrower can read as granted." and "Its missing transfer is closed in this same release …". TRUE at head.
  2. .changeset/18931-me-permissions-unrestricted-export-annotation.md line 13.
  3. .changeset/18990-viewall-only-permissions-seed.md line 12.

Q1 → A, Q2 → A and amendment 2 are carried out as ruled. The out-of-scope rows stand for #20136's enumeration (pointers 5831899863 and 5832366012) and for #20135 (5831905897).

Implemented-by: claude/issue-20134-super-user-seed-bits
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 12:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 437bb0d Sep 25, 2026
41 of 43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20134-super-user-seed-bits branch September 25, 2026 13:12
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ing on apiEnabled: false, export only where the export door admits it (objectstack-ai#20135) (objectstack-ai#20151)

Fixes objectstack-ai#20135

Clause-②: no

`apiOperations`, the operation set `/auth/me/permissions` (and
`ISecurityService.getEffectiveObjectPermissions`) attaches to each
entry, now offers exactly what the REST door serves this subject. It
used to disagree with the door in two places, both in the direction of
offering an operation the door refuses:

- **`enable.apiEnabled: false`.** The door answers `404
OBJECT_API_DISABLED` for every verb. The annotation ignored the switch:
it carried the object's whole closure, or no annotation at all (read by
a client as default-allow) when the subject's export stays allowed on an
otherwise unrestricted object.
- **The export slot** (seat pointer 5831905897).
`annotateEffectiveApiOperations` fell back to the MERGED `'*'` export
bit (`acc.allowExport ?? wildExport`). So a private object reached only
through a plain `'*': { allowExport: true }` was annotated `export`, and
so was an object the exporting set itself names without the grant. The
export door answers both `403 EXPORT_NOT_PERMITTED`.

The fix is in the producer, `annotateEffectiveApiOperations` in
`@objectstack/core`
(`packages/core/src/security/effective-object-permissions.ts`), as
triage directed: 「read the same resolver `enforceApiAccess` reads, so
the two cannot diverge」. The REST door, `packages/spec`
(`resolveEffectiveApiMethods`, `effectiveOperationsArray`,
`apiExposureDenialReason`), `checkObjectPermission`, the seed and the
folds are all untouched. No exported name or type changes.

## What changes

The annotation asks the door's own two questions, entry by entry:

- **The object half** is `canServeApiOperation`
(`@objectstack/spec/data`). It is the boolean face of
`apiExposureDenialReason`, the function `apiAccessDenialFromEnable`
(`enforceApiAccess`) turns into its 404 and 405. The served set is the
closure filtered through it. It judges `apiEnabled === false` first and
for every operation, so an API-disabled object is annotated `[]`. For
any other `enable` the filter is the identity, because the closure
already is what the door admits.
- **The export half** is `objectPermissionGrants(entry, 'allowExport')`:
read and `allowExport` on the entry itself. That is the export door's
conjunction, and `PermissionEvaluator`'s `export` branch documents it as
what the merged per-object entry answers. For the export bit, since
objectstack-ai#20083 and objectstack-ai#20134 the coverage passes put each set's `'*'` grant only on
the entries that set reaches, per posture, so the entry's `allowExport`
already is the per-set, per-posture answer and the merged-bit fallback
is gone. The read half of the conjunction is the entry's read bit, which
still carries the merged fold's read over-grant pre-registered for
objectstack-ai#20136. It moved no cell of the parity column below.

Which entries carry an annotation keeps the objectstack-ai#18931 / objectstack-ai#18990 rule: an
unrestricted object whose every operation is still served, `export`
included, gets none. What moved is the evaluation of "still served": an
API-disabled object never qualifies.

## H4: `apiOperations: []`, and the entry stays

- **What the client reads.** Nothing in this repo's SDK reads
`apiOperations`. `git grep apiOperations -- packages/client
packages/client-react` finds 0 lines; `@objectstack/client` only
re-exports the `GetEffectivePermissionsResponse` type. The reader is
objectui, which is **UNMEASURED** here because the sibling repo is not
in this container. From the spec contract
(`EffectiveObjectPermissionSchema.apiOperations`: "absent =
default-allow") and the objectui code quoted on objectstack-ai#18931 (`effectiveApiOps
? effectiveApiOps.includes('export') : true`), an array hides every
operation it does not list and an absent one hides nothing. `[]` is also
the shape a deny-all (`apiMethods: []`) object already carries, so the
client meets no new shape.
- **Why not drop the entry.** The entry carries the CRUD bits that
`current_user.can()` reads on the server, and it reads an absent entry
as "no grant". `apiEnabled` closes the API, not data access
(`enforceApiAccess`' docblock: "`apiEnabled` controls automatic API
exposure, not data access"). Dropping the entry would change a
server-side `can()` answer, which H5 rules out, and would contradict the
objectstack-ai#18990 ruling that every reachable object gets an entry.

## Measurement

**Door semantics used below.** An operation is "served" when neither
door refuses it: the object door (`404 OBJECT_API_DISABLED` / `405
OBJECT_API_METHOD_NOT_ALLOWED`) and, for `export`, the export door (`403
EXPORT_NOT_PERMITTED`). The operations measured are the ones the REST
door gates by name: `get`, `list`, `create`, `update`, `delete`, `bulk`,
`import`, `export`. `bulk` counts as served when any of `createMany` /
`updateMany` / `deleteMany` passes the door. "Offered" means the entry's
`apiOperations` lists the operation, or the entry has no annotation
(default-allow).

### Real stack (H1, H2): the real `GET /auth/me/permissions` against
real REST requests

This used a throwaway dogfood probe that is **not committed**. It ran
`bootStack` with the real `SecurityPlugin`, `orgContext: true`, 61
registered objects, and one authored app. The app holds `pq_open`
(public), `pq_exposed` (`apiEnabled: true`), `pq_hidden` (`apiEnabled:
false`), `pq_hidden_subset` (`apiEnabled: false` + `apiMethods:
['get','list']`), `pq_subset` (`apiMethods: ['get','list']`),
`pq_private` (`access.default: 'private'`) and `pq_private_hidden`
(private + `apiEnabled: false`). There are 0 such `apiEnabled: false`
objects in `examples/`, so the reach is authored objects only. Every
subject × every registered object × the 10 door requests above went
through the real REST routes, with bodies that cannot mutate. Base is
`16c5a33fdd`; head is this branch.

| subject (resolved sets) | offered but refused, present entries: base →
head | served but hidden: base → head | no-entry cells (unchanged) |
route == member |
|---|---|---|---|---|
| seeded platform admin (`admin_full_access`,
`organization_admin_no_bypass`, `member_default`) | 16 → **0** | 0 → 0 |
0 | yes |
| `admin_full_access` + `member_default` + a plain `'*': { allowExport
}` | 21 → **0** | 0 → 0 | 0 | yes |
| `member_default` | 0 → 0 | 0 → 0 | 134 | yes |
| member + explicit read on the app objects | 16 → **0** | 0 → 0 | 101 |
yes |
| … + a plain `'*': { allowExport }` | 20 → **0** | 0 → 0 | 101 | yes |
| member + explicit read and export on the app objects | 19 → **0** | 0
→ 0 | 101 | yes |
| member + a plain `'*'` with every bit and export | 11 → **0** | 0 → 0
| 15 | yes |
| member + `'*': { viewAllRecords, allowExport }` | 19 → **0** | 0 → 0 |
0 | yes |

- **H1 holds.** At base, `pq_hidden` read `[get, list, create, update,
delete, upsert, bulk, aggregate, search, import]` for the platform
admin, and had no annotation at all for export-allowed subjects.
Meanwhile every REST verb on it answered `404 OBJECT_API_DISABLED`. At
head every API-disabled object reads `[]`.
- **H2 holds.** For `admin_full_access` beside a plain export-only
`'*'`, `sys_secret` read `[get, list, aggregate, search, export]` and
`GET /data/sys_secret/export` answered `403 EXPORT_NOT_PERMITTED`. The
same holds for the authored private `pq_private`, which had no
annotation (default-allow) and a 403 export. The member with explicit
read beside the plain export wildcard shows the same on `pq_private`.
- **Entry-level diff, base-equivalent leg → head, all 8 subjects** (the
base-equivalent leg is the ablation below; its door readings equal true
base in all 8 subjects): 0 entries added or removed, 0 `allow*` bits
changed, **0 operations added to any annotation**, 74 operations
removed, 11 annotations added (9 × `[]`, 2 × the closure minus `export`
on `pq_private`).
- **No-entry cells** are objects the subject's map carries no entry for
(e.g. `member_default` on the `sys_*` objects it cannot read). Whether
an entry exists is the seed's question, and this PR leaves it alone. See
the Acceptance notes.

### Unit fixture (H3): the committed parity column

`plugin-security`'s `get-effective-object-permissions.test.ts` table
covers PR objectstack-ai#20145's 16 subjects plus 2 export-slot subjects. The 2 new
subjects are `admin_full_access` beside a plain export-only `'*'`, and
one set whose exporting `'*'` sits beside an explicit `crm_lead` entry
without the grant. Each subject is checked against every registered
object its map carries (the 11-object `REGISTERED` fixture) and every
door-gated operation. The oracle is `canServeApiOperation` for the
object half, the door's decision function, since plugin-security takes
no dependency on `@objectstack/rest`. For `export` it is the real
registered `svc.canExport`. The subjects also run through the existing
`can()` ↔ `checkObjectPermission` rows, green with `KNOWN_OVER_GRANT`
untouched.

| subject | offered but refused: base → head | served but hidden: base →
head |
|---|---|---|
| wall-less org admin · viewer · one set: plain `'*'` + narrower entry ·
platform admin · platform admin + wall-less · walled org admin · bare
modify-all · super-read + plain bits · one set: super-user `'*'` +
narrower entry | 7 → **0** each (`crm_hidden` × 7) | 0 → 0 |
| explicit entry beside a plain `'*'` · super-user `'*'` + export ·
super-read `'*'` + export · explicit entry beside a super-user `'*'` | 8
→ **0** each (`crm_hidden` × 8, `export` included) | 0 → 0 |
| **platform admin beside a plain export-only `'*'`** | 10 → **0**
(`crm_hidden` × 8, `crm_secret.export`, `sys_secret.export`) | 0 → 0 |
| **one set: exporting `'*'` + explicit `crm_lead` without the grant** |
9 → **0** (`crm_hidden` × 8, `crm_lead.export`) | 0 → 0 |
| member · export-only `'*'` beside a reader · a `'*'` granting nothing
| 0 → 0 | 0 → 0 |

Entry-level over these 18 subjects: 0 entries added or removed, 0
`allow*` bits changed, 0 operations added to any annotation, 92 removed,
7 annotations added. The core pins also hold an export grant without
read to no `export`, and read and export arriving from two different
sets to `export`.

## For `domain:cli` (the route) and `domain:services` (plugin-security):
cross-lane

No code in `plugin-hono-server` or `plugin-security` changes, only their
pins. The bytes `/auth/me/permissions` serves change for the affected
objects, and `getEffectiveObjectPermissions` returns the same map:

- an object declaring `enable.apiEnabled: false` reads `apiOperations:
[]` in every entry. That includes an unrestricted one whose export stays
allowed, which used to carry no annotation;
- `export` leaves the annotation where the export door refuses it: a
private object reached only through a plain wildcard export grant, an
object named without the grant by the set whose wildcard carries it, and
an entry granting export without read. A private, unrestricted object in
that position gains an annotation, its closure minus `export`.

Nothing else moves: no entry, no `allow*` bit, no added operation.
Shape, keys and route are unchanged, and so is every server decision.
`can()` reads the same bits.

## Clause-②

`no`, as the claim carries it. The served annotation narrows only toward
what the door already refuses (0 operations added, measured above), and
the door is untouched, so no request that succeeded now fails. No
exported name or type changes. `annotateEffectiveApiOperations` keeps
its signature. As an exported helper called standalone, it no longer
reads the map's `'*'`, a behaviour change named in the changeset. There
are 0 non-test callers in this repo: every hit of `git grep
annotateEffectiveApiOperations` outside tests is its definition, its
re-export, the composition and comments. `check-changeset-no-major
--base origin/main` exits 0 ("This diff introduces no `major` bump"),
and `check-adr-0087-registration --base origin/main` exits 0 ("1
non-breaking changeset(s) seen"). The changeset grades
`@objectstack/core` `patch`.

## Tests run, at head `f2904cd05b` (core `dist/` rebuilt from this
source)

Each suite ran under the verify lock, and each line quotes its `VERDICT
command-exit 0`:

- `pnpm --filter @objectstack/core test`: 53 files, 1353 tests passed.
`typecheck` exit 0, test layer included.
- `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 323
passed. `typecheck` exit 0.
- `pnpm --filter @objectstack/plugin-security test`: 135 files, 2718
passed. `typecheck` exit 0.
- Dogfood, 18 files, against the `dist/` closure built from this code
(`turbo run build --filter='@objectstack/dogfood^...'`, 64 tasks), in
two runs:
- 126 passed and 1 skipped: `organization-update-door`,
`me-apps-and-everyone-baseline`, `showcase-permission-projection`,
`showcase-permission-seeding`, `showcase-permission-zoo`,
`two-doors-permission`, `comments-permission-matrix`,
`attachments-permission-matrix`, `authz-conformance`;
- 135 passed: `showcase-private-owd`, `owner-anchor-and-bulk-writes`,
`showcase-crud-persona-matrix`, `showcase-client-liaison-fixtures`,
`showcase-fls-read-mask-strip`, `showcase-scope-depth`,
`showcase-scope-depth-write`, `showcase-scope-depth-fallback`,
`showcase-anonymous-deny-surfaces`.
- New and changed pins:
- core `effective-object-permissions.test.ts`: 7 new cases in a
`[objectstack-ai#20135]` block;
- plugin-security `get-effective-object-permissions.test.ts`: 2
subjects, the `apiOperations` column (one case per subject) and a
reported-cases case;
- plugin-hono-server `current-user-endpoints-effective-objects.test.ts`:
a route byte-equality case over an API-disabled and a private object;
- plugin-hono-server `effective-api-operations.test.ts`: 4 API-disabled
cases. Four existing cases were rewritten: one is **inverted on
purpose**, since it pinned the merged-`'*'` fallback itself, and three
called annotate on a map that never went through the per-set passes.
Those three now go through `buildEffectiveObjectPermissions` and assert
the same outcomes.

## Ablation

The mutation restored the base reading in one block: the merged-`'*'`
export bit back, and the door filter off. A string marker kept the dist
reading exact.

- It went through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob
`da81b319` → `33501e91`). Core's `dist/` was rebuilt, and
`ablation-dist-preflight` found the marker in 2 built files.
- **Observed direction: red.**
- core: 5 failed, 22 passed. The two right-direction controls stayed
green.
- plugin-hono-server: 6 failed, 27 passed, over its two pin files
together (`effective-api-operations.test.ts`, 29 cases, and
`current-user-endpoints-effective-objects.test.ts`, 4 cases).
- plugin-security: 16 failed, 36 passed. These are the 15 subject rows
with an API-disabled or export mismatch, and the reported-cases case.
The three subjects with no mismatch stayed green, as did every `can()`
row.
- The stack probe on the ablated build reproduced true base's door
readings in all 8 subjects.
- **Restore.** The blob equals HEAD, and `git diff HEAD` is empty (the
tool's own verdict). Core was rebuilt, and `ablation-dist-preflight
--absent` reports the marker absent from all 14 built files and a clean
tree. All three suites are green again: 27, 33 and 52 passed. A second
ablation leg, run to dump both legs' maps for the tables above, restored
the same way.

## Gates, at head `f2904cd05b`

- The list comes from `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`: 64 commands, derived from 6 paths
against merge base `16c5a33fd`. I ran each one and recorded its exit
code. **64 of 64 exit 0.**
- One needed a second run. `pnpm check:dual-build-cjs-loads` first
answered `PREREQUISITE NOT MET` (exit 3, not measured) because 8
packages had no `dist/`. After `turbo run build` of those 8, all cache
hits, it answered: "105 published require entry point(s) across 67
package(s) load; 660 emitted CommonJS file(s) parse".
- `dispatch-gates --ran`: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".
- `node scripts/check-issue-citations.mjs --base 16c5a33`: exit 0, 3
citations resolve.
- **Lint, as a proven narrowing.** `eslint --no-inline-config --format
json` over the 5 changed TypeScript files reports 5 files, 0 errors and
0 warnings. `eslint --print-config` resolves a config for each of them,
and eslint ignores the changeset. `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, no typed rules, as its
own comment states), so this diff cannot move any untouched file's
verdict. The repo-wide `pnpm lint` is CI's.
- **NOT MEASURED locally** (CI owns them): Test Core, Dogfood Regression
Gate, Build Core, Temporal Conformance, the type-check lanes, and the
families `dispatch-gates` names outside its 64.

## DELIBERATE CORRECTION: three pending changesets (seat amendment 2,
5851881633)

Each change rewrites ONE sentence of a pending release note that this PR
makes false, following the objectstack-ai#20132 / objectstack-ai#20145 precedent. Every other line
of each file is byte-identical: `git diff --numstat f2904cd HEAD`
reads `1 1` on each file, and with the changed line deleted from both
sides `diff` exits 0. Check Changeset turns red on the foreign-changeset
rule by design, naming exactly these three files.

### 1.
`.changeset/18931-me-permissions-unrestricted-export-annotation.md`,
line 13 (blob `6eac3716` → `d49f56e3`)

- **Before:** 「an object that is unrestricted **and** keeps `export`
gets none.」
- **After:** 「an object that is unrestricted **and** keeps `export` gets
none — unless its `enable.apiEnabled` is `false`, which is annotated
`[]` since objectstack-ai#20135 because the REST door answers 404 for every verb on
it.」

### 2. `.changeset/18990-viewall-only-permissions-seed.md`, line 12
(blob `261e412f` → `727fbffa`)

- **Before:** 「… an unrestricted object whose export stays allowed still
gets no `apiOperations` (the entry itself is seeded since objectstack-ai#20134),
because for it the client's default-allow path is already right.」
- **After:** 「… an unrestricted object whose export stays allowed still
gets no `apiOperations` (the entry itself is seeded since objectstack-ai#20134),
because for it the client's default-allow path is already right — except
an object with `enable.apiEnabled: false`, annotated `[]` since objectstack-ai#20135
because the REST door refuses every verb on it.」

### 3. `.changeset/20134-super-user-entries-every-bit.md`, line 15 (blob
`a113da40` → `d2ee6a58`)

- **Before:** 「That entry carries no `apiOperations`, exactly as the
operation channel said nothing about the object before, so a client's
default-allow path for the operation set is unchanged.」
- **After:** 「That entry carries no `apiOperations` — unless the object
declares `enable.apiEnabled: false`, which is annotated `[]` since
objectstack-ai#20135 — so for every other such object the operation channel says what
it said before and a client's default-allow path is unchanged.」

**The new sentences are TRUE at this head.** An object with
`enable.apiEnabled: false` is annotated `[]` in every entry, and the
REST door answers `404 OBJECT_API_DISABLED` for every verb on it; both
were measured on the stack above. Every other unrestricted object whose
export stays allowed still carries no annotation. That holds for the
controls `pq_open` and `pq_exposed`, and for `crm_account` in the core
pins.

**This card's own changeset follows.** Its line 21 said those notes
"otherwise describe [such an object] as carrying none", which the
corrections make false. That one sentence now reads 「That includes an
unrestricted object whose export stays allowed, which used to carry no
annotation at all; this release's notes for objectstack-ai#18931, objectstack-ai#18990 and objectstack-ai#20134
name that exception.」 `git diff --numstat` reads `1 1`, and every other
line is byte-identical.

## Patch round 1, at head `ba66a482b2` (contract review 5851878434,
prose only)

Two commits sit on top of `f2904cd05b`: the three corrections, then this
card's changeset sentence. They touch only those four `.changeset`
files. Every code and test blob is identical to `f2904cd05b`: `git diff
--stat f2904cd HEAD -- . ':(exclude).changeset'` is empty, and the
five files carry blobs `da81b3192b`, `cd0367c631`, `ab113b718a`,
`070606df33` and `0ea90cbfed` at both heads. So the suites, ablation,
stack and gate readings above stand for the code. The branch was not
merged with `main`, and its merge base is still `16c5a33fdd`.

Gates re-run at `ba66a482b2`, with `--base 16c5a33` (the merge base)
and this body as the `--event`:

- `node scripts/check-empty-changeset.mjs --base 16c5a33`: **exit 1,
as expected**. It names exactly the three corrected files, each as
"present on the merge base and CHANGED by this PR -- this is somebody
else's release note":
`.changeset/18931-me-permissions-unrestricted-export-annotation.md`,
`.changeset/18990-viewall-only-permissions-seed.md` and
`.changeset/20134-super-user-entries-every-bit.md`. Its first line is "✓
No empty-frontmatter changeset introduced by this diff (4 declaring
changeset(s) added)". The class is DELIBERATE CORRECTION, not COLLISION,
so the base text must **not** be restored; a same-head contract-review
PASS is what confirms it.
- `node scripts/check-changeset-no-major.mjs --base 16c5a33 --event`
(this body): exit 0. It prints "✓ This diff introduces no `major` bump."
and "✓ LEVEL AXIS: this PR declares clause-② `no`, so no package here is
declared to have grown a published surface.", with declaration line
`Clause-②: no` and no direction arm.
- `node scripts/check-adr-0087-registration.mjs --base 16c5a33
--event` (this body): exit 0, "✓ check-adr-0087-registration: this PR
adds no declared-breaking changeset (4 non-breaking changeset(s) seen)."
- `node scripts/check-issue-citations.mjs --base 16c5a33`: exit 0, "✅
check-issue-citations: every citation this change adds resolves (or is a
declared cross-repo reference)." (3 judged, 3 resolve).
- `origin/main` has since moved one commit, to `8d1f7ab785` (objectstack-ai#20125). It
touches none of the six files in this diff, and in
`packages/core/src/security` it touches only `operation-private-keys.ts`
and its pin. The branch was not merged, as the seat directed.

## Acceptance notes

- **Entry presence is untouched, and it has its own mismatches.** On the
stack, a subject whose map has no entry for an object (the "no-entry
cells" column) leaves the client on default-allow for that object's
operations while the door refuses some of them. Two examples:
`member_default` on `sys_metadata` `create`, and `export` on objects it
cannot read. Those counts are identical at base and head. Which entries
exist is the seed's and the folds' question: it is excluded from this
claim, and objectstack-ai#20136 is next in this file. It is not filed here. `can()`
reads such an object as "no grant" on every verb, which is the
entry-presence ruling's territory (objectstack-ai#18990).
- **Pending release notes, corrected in this PR.** Seat amendment 2
(5851881633 on objectstack-ai#20135) ruled Q1 → B after contract review 5851878434.
The review measured `resolveEffectiveApiMethods({ apiEnabled: false
}).mode === 'unrestricted'`, so an API-disabled object with no
`apiMethods` is "unrestricted" in the resolver's own vocabulary, and all
three pending sentences read false for it at this head. Each gets a
one-clause DELIBERATE CORRECTION; see the section below.
- **Spec describe string.**
`EffectiveObjectPermissionSchema.apiOperations`' `.describe()` says
"Present only when the object tightens exposure via apiMethods". That
has been inexact since the export axis (objectstack-ai#3544), and is now inexact for
`apiEnabled: false` too. `packages/spec` is out of scope, so this is
noted only.
- **`history` / `restore` / `purge` / `search` are outside the parity
column** by construction. No REST route gates them by name. The spec's
`apiExposureDenialReason` admits every operation on an unrestricted
object, while `resolveEffectiveApiMethods` withholds the flag-gated
ones, and that difference lives in `packages/spec`. It is unchanged, and
the annotation keeps the closure's answer.
- The stack probe and the ablation dump were throwaway files and are not
in this diff. The tree was proven clean against HEAD after both.

Session `session_01Bvd69VPa6puiNzzPUroDBx` (the `domain:engine` seat 1
dispatch, round 22), on branch
`claude/issue-20135-api-operations-parity`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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