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
Conversation
…mission map buildEffectiveObjectPermissions merged each set's explicit entries and kept '*' as a key of its own, so an object covered only by a plain wildcard (no super-user bit) had no entry, and current_user.can() read it as no grant where PermissionEvaluator.checkObjectPermission allows. A new pass puts each set's plain '*' grants on the registered public objects that set does not name, after the super-user seed and before the fold. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
core: the coverage pass's rules (registered public objects only, a set's explicit entry excludes its own wildcard, another set's wildcard widens a present entry, true bits only, no no-verb entries, super-user wildcards left to the seed and fold, seed-before-cover ordering). plugin-security: a table-driven parity pin — shipped and authored plain wildcard subjects x registered objects x every can() verb, the real can() over the member's map against PermissionEvaluator.checkObjectPermission; the member and route byte-equality pins now exercise the new pass. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
The changeset tells /auth/me/permissions and getEffectiveObjectPermissions readers what the objects slot gains. The access-security parity item gains the wall-less org admin persona, its step and acceptance clause, the producer's source anchor and the unit parity table in automated.ref. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…geset minor The coverage step reads access.default off each allSchemas entry. Typing the member unknown keeps every call that compiled before compiling; the element type still gains an optional member, a type widening, so the changeset is graded minor. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Measured on a booted showcase: showcase_semantic_zoo is named by no set and is absent from the wall-less org admin's map before this change, while showcase_project is named read-only by showcase_member_default and widened by the organization_admin_no_bypass wildcard. The step and clause now say so. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c2c05bffb37e23885d6e30a366c0325fc70898b3 && git checkout c2c05bffb37e23885d6e30a366c0325fc70898b3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bc80e16260597d52b062b7e1ee810c7b9a99aed2 228724b3f35bddf84f42190db316624e264cdad4 && git checkout -B drift-repro bc80e16260597d52b062b7e1ee810c7b9a99aed2 && git merge --no-ff 228724b3f35bddf84f42190db316624e264cdad4
node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2
|
…p is closed in this release Deliberate correction of the pending 18783-server-can-option-visibility changeset: its "Known gap, not changed here" paragraph described the plain-wildcard coverage this branch adds, and read false once it lands in the same release. Only that paragraph is rewritten; it names this branch's changeset and states the remaining super-user divergence without claiming a fix. Every other sentence of the file is byte-identical. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: 7 files (+508/−29) on merge base
No governed surface and no Measured in detached worktrees at head and base, each with its closure built. ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ry every bit the server grants, so can(object, 'transfer') agrees with checkObjectPermission (objectstack-ai#20134) (objectstack-ai#20145) Fixes objectstack-ai#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: - **`transfer`.** The fold put only read, create, edit and delete on an entry, so `can(X, 'transfer')` was `false` for `admin_full_access` on every object, and for the walled `organization_admin` on every object its own set does not name. The server grants `transfer` through `modifyAllRecords`. - **A super-read wildcard's own plain bits.** `'*': { viewAllRecords, allowEdit }` put only the read on an entry. - **A super-user wildcard carrying `allowExport`.** It skipped the super-user seed for every unrestricted object. This is the third location of the family recorded on objectstack-ai#20136, measured at 214 cells by the reviewer of objectstack-ai#20132. 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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#18990 ruling and the seed's skip The pins this PR inverts in `plugin-hono-server`'s `effective-api-operations.test.ts` quote the objectstack-ai#18990 ruling (batch objectstack-ai#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 objectstack-ai#20134). The objectstack-ai#18990 ruling line (5729478681), quoted verbatim: > Ruling: batch objectstack-ai#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 objectstack-ai#18931; PR objectstack-ai#18984's `toBeUndefined` pin flips on purpose) · maintainer 「同意」 2026-09-18T11:42Z The entry is owed. The objectstack-ai#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 (objectstack-ai#20136) The fold is broader than the evaluator in two places. Both are OVER-grants and belong to objectstack-ai#20136, the family's over-grant card, which remains open. This PR leaves both byte-identical. - **The merged bypass folded into an entry the super-user set itself names narrower.** On the 63-object fixture this is the walled `organization_admin`'s 38 cells (create, edit, delete and import on its five read-only RBAC objects, and edit on `sys_organization`). A one-set authored shape shows 7. - **`allowCreate` pulled on `modifyAllRecords` alone.** The spec's `objectPermissionGrants` gives this no create cell. A bare `'*': { modifyAllRecords: true }` shows 36 create/import cells. No shipped set has this shape, since both shipped super-user wildcards carry `allowCreate: true`. This second location is not in objectstack-ai#20136's body. The seat has routed it to objectstack-ai#20136's enumeration (amendment 5831894689). `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 objectstack-ai#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-② - **The behavioural accept set widens.** `can()` answers widen to match enforcement, and a `can()`-gated write for these subjects is now admitted. Nothing narrows (see the entry-level diff above), so `@objectstack/core` is `minor`, following the objectstack-ai#20083 and objectstack-ai#18783 precedents. - **Public type:** no exported name or type changes. - `check-changeset-no-major --base origin/main` and `check-adr-0087-registration --base origin/main` both exit 0. ## 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 objectstack-ai#20083 precedent. Every other line of each file is byte-identical. - **Q1 → A** (seat amendment 5831894689 on objectstack-ai#20134) covers the objectstack-ai#18783 clause. - The contract review FAIL 5832348775 (prose only) names the objectstack-ai#18931 and objectstack-ai#18990 bullets, and seat amendment 2 (5832355884) rules them corrected in this PR. ### 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 objectstack-ai#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): - **「an entry the super-user set itself names narrower can read as granted」** holds. These are objectstack-ai#20136's cells: 38 for the walled `organization_admin` on the 63-object fixture, and 7 for the one-set authored shape. Both are byte-identical at base and head and pre-registered in `KNOWN_OVER_GRANT`. - **「Its missing `transfer` is closed in this same release」** holds. `transfer` under-grants are 0 in every super-user row of the fixture table and 78 → 0 on the booted showcase. - **The `create`-on-`modifyAllRecords`-alone cells are not named there, and do not need to be.** The sentence does not claim to list every divergence. That over-grant is disclosed in this release's own `.changeset/20134-super-user-entries-every-bit.md` (「Still broader than the server, unchanged here」), and no shipped set has the shape. Naming it in the objectstack-ai#18783 entry would widen the correction beyond the one clause Q1 authorizes. ### 2. `.changeset/18931-me-permissions-unrestricted-export-annotation.md`, line 13 (one bullet) - **Before:** 「**The seed now applies annotate's own predicate**: resolve with the export slot annotate will read for the entry being seeded, and 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, so annotate's `acc.allowExport ?? wildExport` resolves to the same wildcard bit the seed read — the two passes cannot diverge again.」 - **After:** 「**The seed and annotate cannot diverge again.** Since objectstack-ai#20134 the seed places an entry for every registered object a super-user wildcard reaches that the merge left without one, and `annotateEffectiveApiOperations` alone decides which entries carry `apiOperations`: an object that is unrestricted **and** keeps `export` gets none.」 **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) - **Before:** 「**`apiOperations` is attached through the predicate already shared with the modify-all class** — an unrestricted object whose export stays allowed is still skipped, because for it the client's default-allow path is already right.」 - **After:** 「**`apiOperations` is attached through the predicate already shared with the modify-all class** — 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.」 **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 objectstack-ai#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. ```text $ node scripts/check-empty-changeset.mjs --base a08e059 -> 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 a08e059 --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 a08e059 -> 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 a08e059 -> 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 objectstack-ai#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:** - plugin-security `get-effective-object-permissions.test.ts`: the objectstack-ai#20083 parity table gains 9 super-user rows, plus 2 spelled-out cases and a registration-integrity case (31 tests). - core `effective-object-permissions.test.ts`: a 5-case `[objectstack-ai#20134]` battery, and one updated key-order assertion (20 tests). - plugin-hono-server: 3 seed pins inverted, and 2 route assertions added. **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 a08e059` 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
…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>
Fixes #20083
Clause-②: yes (widening)
The effective object-permission map that
/auth/me/permissionsserves asobjectsand thatISecurityService.getEffectiveObjectPermissionshands tocurrent_user.can()now covers what a plain'*'grant covers. A plain wildcard is one with neither super-user bit. Before, a wall-less org admin (organization_admin_no_bypass) had no entry for an object reached only through that wildcard.can('crm_account', 'edit')answeredfalse, whilePermissionEvaluator.checkObjectPermission('update', 'crm_account', sets)answeredtrue.The fix is at the producer,
buildEffectiveObjectPermissionsin@objectstack/core. Formula'scan()is untouched, and so is its 「absent = no grant」 rule. Noengine.tsplumbing is touched either.What changes
A new step,
materializePlainWildcardCoverage(module-private), runs after the super-user seed and before the fold. For each registered object it applies each set's plain'*'the wayresolveObjectPermissionresolves that set:access.defaultis not'private'), because the server refuses an object whose posture it cannot resolve;truegrant bits are copied (allowRead,allowCreate,allowEdit,allowDelete,allowTransfer,allowExport);A super-user wildcard is left to the seed and the fold, exactly as before.
The step reads
accessoff eachallSchemasentry. The source's element type therefore gains an optionalaccess?: unknownmember. The package exports no new name for it. This is why the declaration line above readsyes (widening)where the claim readsno, and why the changeset grades@objectstack/coreminor. See Clause-② below.For
domain:cli(the route) anddomain:services(plugin-security)No code in
plugin-hono-serverorplugin-securitychanges, only their pins. What their readers see:GET /auth/me/permissions→objects. A subject holding a plain wildcard gains one entry for every registered public object the wildcard covers that had none. Each new entry is annotated withapiOperationsby the same rule as every other entry. An existing entry may gaintruebits. Nothing is removed. The shape, the keys and the route are unchanged.ISecurityService.getEffectiveObjectPermissions. The same map, since it is the same function. The engine'scan()-gated option write path therefore admits the wall-less org admin wherever the data plane does.admin_full_access, a walledorganization_admin, andmember_defaultalone.admin_full_access+organization_admin_no_bypass+member_defaultwas byte-identical too in both measurements below.Measurement
The real
can()is formula'sExpressionEngineovertoEvalPermissions(map). It is compared withPermissionEvaluator.checkObjectPermissionfor every verb the vocabulary accepts:create,delete,edit,export,import,read,remove,transfer,update,write.overmeans the map grants a verb the evaluator refuses.undermeans the map refuses a verb the evaluator grants.underdoes not count create/edit/delete refused on a guarded managed object: the managed-write clamp narrows those on purpose.Live showcase. Measured on
bootStack(showcase), with the realsecurityservice and the real registry, which holds 78 objects (780 cells per subject). The base leg is this head with only the coverage call ablated anddist/rebuilt. Apart from comments, types, the now-uncalled helpers and a hoistedallSchemasread, it behaves like base7b27bd00c7.showcase_member_default+organization_admin_no_bypass+member_default)viewer_readonly+ baselines)organization_admin+ baselines)admin_full_access+ baselines)For the wall-less org admin on the showcase:
showcase_semantic_zoois named by no set. It was ABSENT and is now{allowCreate, allowRead, allowEdit, allowDelete: true}.showcase_projectis named read-only byshowcase_member_default. It readallowEdit: falseand now readstrue, because theorganization_admin_no_bypasswildcard applies to it for that set.sys_secretis private, and it stays absent.Unit fixture, base
7b27bd00c7against head. This run uses the real shipped sets fromdefaultPermissionSets. The registry holds 52 platform objects, 7 plugin-security objects and 4 app objects:crm_account(public),crm_lead(restricted byapiMethods),crm_secret(private) andcrm_hidden(apiEnabled: false). The map was read three ways: from the direct producer, from the real/auth/me/permissionshandler and from the real SecurityPlugin member. All three were byte-equal in every row.organization_admin_no_bypass+member_defaultviewer_readonly+member_defaultcrm_account: readbeside another set's'*': read, edit, export'*': read, edit, deleteand explicitcrm_account: readcrm_accountedit stays refused on both sides)crm_account: readbeside'*': exportorganization_admin,admin_full_access,member_default, the dev owner's three sets, an all-false'*'Write path. This used the real ObjectQL engine, with the SecurityPlugin member as its resolver, and an option gated on
current_user.can('crm_account', 'edit'):VALIDATION_FAILED/invalid_optionwith 0 rows at base; admitted with 1 row and 0 warns at head;Clause-②
17.4.0, from 2026-09-09 (npm).current_user.canshipped in no release:.changeset/18545-formula-can-permission-predicate.mdand.changeset/18783-server-can-option-visibility.mdare both still pending onorigin/main. Nothing moves relative to a release.buildEffectiveObjectPermissions' schema source gains an optionalaccess?: unknownon itsallSchemaselement. A reverse check showstscin plugin-security reads the rebuiltdist/index.d.ts. A value typed as that element carryingaccessis accepted, and the same value carrying an undeclaredposturekey is refused TS2353, namingApiExposureSchemaLike & ObjectAccessPostureLike. Per the [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 precedent, where an added optional member was graded a public widening, this readsyes (widening), and core isminor. The fixed group already goes minor in the next release through the pending Registercanin the@objectstack/formulaCEL engine function table — the objectstack half of the ruled cross-repo split forcurrent_user.can(object, verb)(objectui#4421 batch #13) #18545 / [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 changesets.Consumers of the response
No non-test code in
packages/orexamples/requests/auth/me/permissions: the one hit is the route's own registration. As a control, the same spelling finds 21 lines in 8 test files. The member's one consumer is the engine resolver the security plugin registers. Every in-repo reader stayed green (suites below).objectui's
can()is UNMEASURED. The sibling repo is not reachable here, andpackages/consoleholds no bundle.Tests run, at head
403f653799withdist/rebuiltThe suites and typechecks below ran as one
&&chain under the verify lock:VERDICT command-exit 0.pnpm --filter @objectstack/core test: 53 files, 1341 tests passed.pnpm --filter @objectstack/plugin-security test: 135 files, 2685 tests passed.pnpm --filter @objectstack/plugin-hono-server test: 27 files, 317 tests passed.typecheckfor core, plugin-security and plugin-hono-server: exit 0, test layers included.dist/closure built from this code: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-matrixandauthz-conformance. Result: 126 passed, 1 skipped.effective-object-permissions.test.ts(15 tests);get-effective-object-permissions.test.ts(19 tests). This includes the table-driven parity pin: plain-wildcard subjects × registered objects × every verb, the realcan()againstcheckObjectPermission;current-user-endpoints-effective-objects.test.ts(3 tests). Its route byte-equality pin now exercises the new step.Ablation. The mutation removed the coverage call, placing a marker instead:
403f653799throughscripts/ablation-replace.mjs: anchor 1 → 0, blobf8e0efad→80ea1874.dist/was rebuilt, andablation-dist-preflightfound the marker in 2 built files.git diff HEADis empty, andgit status --porcelainis clean. After a rebuild the marker is absent fromdist/and the call spelling is present in 2 built files. All three suites are green again: 19, 15 and 3 passed. The first restore attempt was a queue timeout (exit 99, never acquired), and it was re-taken with the same slot.Gates, at head
403f653799node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, which derived 66 commands for this diff. I ran each one and recorded its exit code.pnpm check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3) because 8 unrelated packages had nodist/, which is NOT MEASURED rather than red. After building those 8 packages it answered: 105 published require entry points across 67 packages load, 660 emitted CommonJS files parse, 1 cross-format probe agrees.dispatch-gates --ran: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every family recorded an exit code).node scripts/check-issue-citations.mjs --base 7b27bd00c7: exit 0, 5 citations resolve.dispatch-gatesnames outside this list (Test Core, Dogfood, Build Core, Temporal, the type-check lanes) are NOT MEASURED locally, because CI owns them. So is the repo-widepnpm lint.Patch round 1, at head
228724b3f3(seat rulings on the report's two open questions)Written by session
session_01Bvd69VPa6puiNzzPUroDBx, the same session that opened this PR.Clause-②: yes (widening)stands, and@objectstack/corestaysminor, on the [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 precedent for an added optional member of a published parameter type.403f653799rewrites only the "Known gap, not changed here" paragraph of.changeset/18783-server-can-option-visibility.md. Every other line of that file is byte-identical:git diffshows 1 line removed and 1 added. Nothing else changed, and the branch was not merged withmain.228724b3f3(exit codes):node scripts/check-empty-changeset.mjs --base origin/main: 1. This is the foreign-changeset rule refusing.changeset/18783-server-can-option-visibility.md, which is expected; see the first Acceptance note.--self-testofcheck-empty-changeset;check-changeset-no-major --base origin/mainand its--self-test("This diff introduces nomajorbump");check-adr-0087-registration --base origin/mainand its--self-test("2 non-breaking changeset(s) seen").pnpm check:changeset-gate-self-tests,pnpm check:objectui-changeset,pnpm check:pm-changeset-deadline-census,pnpm check:doc-authoring,pnpm check:nul-bytes,pnpm check:issue-citations.node scripts/check-issue-citations.mjs --base 7b27bd00c7(5 citations resolve).dispatch-gates --commandsat this head derives the same 66 commands as at403f653799. No code, test or checklist file moved, so the full-suite, ablation and gate readings above stand for the code.Acceptance notes
The parity table also shows three defects that were there before this PR. This PR leaves them unchanged, and none of them is fixed here.
organization_admin: 38 over cells, fixture and showcase alike).foldWildcardSuperUserfolds the merged'*'bypass into every entry, including entries that the super-user set itself names narrower.resolveObjectPermissionanswers that set with its explicit entry.sys_position,sys_permission_set,sys_position_permission_set,sys_user_permission_setandsys_user_position, and edit onsys_organization, where the evaluator refuses.current_user.can('sys_position', 'edit')is ADMITTED for a walled org admin (measured), whilecheckObjectPermission('update', 'sys_position')isfalse.can(X, 'transfer')isfalseforadmin_full_accessand a walledorganization_adminon every object, wherecheckObjectPermission('transfer')istrue: 63 fixture cells and 78 showcase cells. The seed initialises entries all-false, and the fold lifts only read, create, edit and delete. The same holds for a super-read wildcard carrying plain bits too. This direction fails closed.apiOperationsignoresenable.apiEnabled: false. An object declaring it with noapiMethodsis annotated with the full operation list, while REST answers 404 for it. This is pre-existing on seeded and explicit entries, and no example app declares such an object..changeset/18783-server-can-option-visibility.md(seat ruling Q1 A). That changeset landed with feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079 and is still unreleased. Its "Known gap, not changed here" paragraph describes exactly the plain-wildcard gap this PR closes, and would have shipped false in the same release. The paragraph is rewritten so that every sentence is true at this head. It names this PR's changeset and states the remaining super-user divergence in one neutral sentence, without claiming a fix.check-empty-changeset/ Check Changeset goes red on the foreign-changeset rule by design. Perlanding-operations.md, a contract-tier review PASS on this same head is what confirms a DELIBERATE CORRECTION red; the class is not a COLLISION, so the base text must not be restored.Base text, at
7b27bd00c7(unchanged since0318faf692landed). The file writes the object placeholder inside angle brackets; it is written here asTHAT_OBJECTbecause the GitHub body sanitizer drops angle-bracket fragments:Head text, at
228724b3f3:Left byte-identical as ruled, and flagged for the reviewer: that changeset's sentence "The
/auth/me/permissionsresponse is byte-identical for the same resolved sets (measured on five fixtures against the previous build)." It describes feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079's move of the merge into core, and it holds for that move. Read against the previous release, though, the response of a plain-wildcard subject now changes in this same release, through this PR.origin/main(7a13e0562a,7c1039b388,55daf89d74,226e00c038). They touch service-analytics, driver-turso and the pm-dispatch skill, and none of their paths is in this diff's packages or their dependency closure.