feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen - #20079
Conversation
…a passed permission map evaluateValidationRules takes the acting subject's effective object permissions as `permissions` and hands them to the per-option visibleWhen evaluation, so a grant-gated option is refused on a clean FALSE instead of failing open. `undefined` stays "no permission data" (can() refuses loudly and the fail-open branch names it); an empty map is a real answer. optionVisibilityReadsPermissions tells the engine whether a write picks an option whose predicate calls can(), off the same picker the evaluator uses. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…d the security service - core: buildEffectiveObjectPermissions (merge -> seed -> fold -> clamp -> annotate), with the four folds moved from plugin-hono-server, which re-exports them under the same names. - plugin-hono-server: /auth/me/permissions builds its objects slot with it. - plugin-security: implements ISecurityService.getEffectiveObjectPermissions over the same function, and registers it on the engine. - objectql: registerEffectiveObjectPermissionsResolver; each write resolves the map at most once, only when it picks an option whose visibleWhen calls can(), and fails closed on a resolution throw. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Enforced on insert, by-id update, bulk update and validate(); one resolution per write across N rows; never kept across writes; a write whose gates never call can() never asks; no resolver leaves the gate loudly unevaluable; a throwing resolver or an off-shape map fails the write closed with its own error. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
core: buildEffectiveObjectPermissions merge rule, order, guards, no aliasing. plugin-hono-server: /auth/me/permissions objects is that function over the resolved sets, byte for byte. plugin-security: the member is reachable on the registered literal, equals the endpoint's computation, is whole, throws untouched, is request-scoped, and is the function registered on the engine. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…ace to what consumers import Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…rver-can-permissions
…ive-map fixture Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…aller's bound Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 144 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 b2f924207c19363271885b56fe10dba87ad9fa6f && git checkout b2f924207c19363271885b56fe10dba87ad9fa6f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4c897e0e700d96e29ff8a39e91c85f2a8a30909 7a6b091b2a6f589481398f5772d107f02b9071fe && git checkout -B drift-repro d4c897e0e700d96e29ff8a39e91c85f2a8a30909 && git merge --no-ff 7a6b091b2a6f589481398f5772d107f02b9071fe
node scripts/docs-audit/affected-docs.mjs --json d4c897e0e700d96e29ff8a39e91c85f2a8a30909
|
Contract reviewServed-tier: Scope read: card #18783 and all 7 comments (ruling 5725678115, retriage, re-derivation, unlock, claim, the Every base-vs-head reading uses the merge base ① Derived judgments
② Semver levelcore ③ Boundary flags
Implemented-by: VERDICT: PASS Isolated reviewer: a contract-review-tier subagent, fed only the cards, the ruling, the PR and AGENTS.md; adopted by the seat. |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36088336600 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…eEffectiveApiOperations now lives The fold moved from plugin-hono-server's current-user-endpoints.ts into @objectstack/core's security/effective-object-permissions.ts, so the old citation named a file with 0 mentions of allowExport and the key-mention signal reported it UNANCHORED. Re-closed the entry's three legs and stamped verifiedAt. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: the delta over PASS 5825901229 (at ① Derived judgments
The judgments of PASS 5825901229 stand for everything outside this delta. ② Semver levelThe delta moves no schema, export, authorable key or ledger status. Nit: ③ Boundary flags
Implemented-by: VERDICT: PASS Isolated reviewer: a contract-review-tier subagent, scoped to the delta over record 5825901229; adopted by the seat. |
…nt, so current_user.can() agrees with checkObjectPermission for a wall-less org admin (objectstack-ai#20083) (objectstack-ai#20132) Fixes objectstack-ai#20083 Clause-②: yes (widening) The effective object-permission map that `/auth/me/permissions` serves as `objects` and that `ISecurityService.getEffectiveObjectPermissions` hands to `current_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')` answered `false`, while `PermissionEvaluator.checkObjectPermission('update', 'crm_account', sets)` answered `true`. The fix is at the producer, `buildEffectiveObjectPermissions` in `@objectstack/core`. Formula's `can()` is untouched, and so is its 「absent = no grant」 rule. No `engine.ts` plumbing 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 way `resolveObjectPermission` resolves that set: - only registered objects, and only public ones (`access.default` is not `'private'`), because the server refuses an object whose posture it cannot resolve; - a set that names the object contributes nothing more, since its explicit entry is that set's whole answer; - another set's plain wildcard widens an entry that is already present, bit by bit; - only `true` grant bits are copied (`allowRead`, `allowCreate`, `allowEdit`, `allowDelete`, `allowTransfer`, `allowExport`); - an entry the step would ADD is dropped when it grants no verb on its own, so an export-only wildcard adds no entries. A super-user wildcard is left to the seed and the fold, exactly as before. The step reads `access` off each `allSchemas` entry. The source's element type therefore gains an optional `access?: unknown` member. The package exports no new name for it. This is why the declaration line above reads `yes (widening)` where the claim reads `no`, and why the changeset grades `@objectstack/core` `minor`. See **Clause-②** below. ## 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`.** 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 with `apiOperations` by the same rule as every other entry. An existing entry may gain `true` bits. 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's `can()`-gated option write path therefore admits the wall-less org admin wherever the data plane does. - **Byte-identity.** Subjects holding no plain wildcard get a byte-identical response: `admin_full_access`, a walled `organization_admin`, and `member_default` alone. `admin_full_access` + `organization_admin_no_bypass` + `member_default` was byte-identical too in both measurements below. ## Measurement The real `can()` is formula's `ExpressionEngine` over `toEvalPermissions(map)`. It is compared with `PermissionEvaluator.checkObjectPermission` for every verb the vocabulary accepts: `create`, `delete`, `edit`, `export`, `import`, `read`, `remove`, `transfer`, `update`, `write`. `over` means the map grants a verb the evaluator refuses. `under` means the map refuses a verb the evaluator grants. `under` does 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 real `security` service and the real registry, which holds 78 objects (780 cells per subject). The base leg is this head with only the coverage call ablated and `dist/` rebuilt. Apart from comments, types, the now-uncalled helpers and a hoisted `allSchemas` read, it behaves like base `7b27bd00c7`. | subject (resolved sets) | entries | under | over | bytes | |---|---|---|---|---| | wall-less org admin (`showcase_member_default` + `organization_admin_no_bypass` + `member_default`) | 51 → 80 | 215 → **0** | 0 → 0 | 11885 → 16927 | | viewer (`viewer_readonly` + baselines) | 46 → 80 | 34 → **0** | 0 → 0 | 10471 → 15500 | | walled org admin (`organization_admin` + baselines) | 81 → 81 | 45 → 45 | 38 → 38 | identical | | platform admin (`admin_full_access` + baselines) | 81 → 81 | 78 → 78 | 0 → 0 | identical | | member (baselines only) | 45 → 45 | 0 → 0 | 0 → 0 | identical | For the wall-less org admin on the showcase: - `showcase_semantic_zoo` is named by no set. It was ABSENT and is now `{allowCreate, allowRead, allowEdit, allowDelete: true}`. - `showcase_project` is named read-only by `showcase_member_default`. It read `allowEdit: false` and now reads `true`, because the `organization_admin_no_bypass` wildcard applies to it for that set. - `sys_secret` is private, and it stays absent. **Unit fixture, base `7b27bd00c7` against head.** This run uses the real shipped sets from `defaultPermissionSets`. The registry holds 52 platform objects, 7 plugin-security objects and 4 app objects: `crm_account` (public), `crm_lead` (restricted by `apiMethods`), `crm_secret` (private) and `crm_hidden` (`apiEnabled: false`). The map was read three ways: from the direct producer, from the real `/auth/me/permissions` handler and from the real SecurityPlugin member. All three were byte-equal in every row. | subject | under | over | |---|---|---| | `organization_admin_no_bypass` + `member_default` | 106 → **0** | 0 → 0 | | `viewer_readonly` + `member_default` | 32 → **0** | 0 → 0 | | explicit `crm_account: read` beside another set's `'*': read, edit, export` | 171 → **0** | 0 → 0 | | ONE set with `'*': read, edit, delete` and explicit `crm_account: read` | 144 → **0** (`crm_account` edit stays refused on both sides) | 0 → 0 | | explicit `crm_account: read` beside `'*': export` | 1 → **0** | 0 → 0 | | walled `organization_admin`, `admin_full_access`, `member_default`, the dev owner's three sets, an all-false `'*'` | unchanged, byte-identical | unchanged | **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')`: - wall-less org admin: refused `VALIDATION_FAILED` / `invalid_option` with 0 rows at base; admitted with 1 row and 0 warns at head; - member, and the one-set-narrower subject: refused at both, where the evaluator also refuses. ## Clause-② - **Behavioural accept set, against the last release.** The last release is `17.4.0`, from 2026-09-09 (npm). `current_user.can` shipped in no release: `.changeset/18545-formula-can-permission-predicate.md` and `.changeset/18783-server-can-option-visibility.md` are both still pending on `origin/main`. Nothing moves relative to a release. - **Public type.** `buildEffectiveObjectPermissions`' schema source gains an optional `access?: unknown` on its `allSchemas` element. A reverse check shows `tsc` in plugin-security reads the rebuilt `dist/index.d.ts`. A value typed as that element carrying `access` is accepted, and the same value carrying an undeclared `posture` key is refused TS2353, naming `ApiExposureSchemaLike & ObjectAccessPostureLike`. Per the objectstack-ai#18783 precedent, where an added optional member was graded a public widening, this reads `yes (widening)`, and core is `minor`. The fixed group already goes minor in the next release through the pending objectstack-ai#18545 / objectstack-ai#18783 changesets. ## Consumers of the response No non-test code in `packages/` or `examples/` 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, and `packages/console` holds no bundle. ## Tests run, at head `403f653799` with `dist/` rebuilt The 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. - `typecheck` for core, plugin-security and plugin-hono-server: exit 0, test layers included. - Dogfood, 9 files, against the `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-matrix` and `authz-conformance`. Result: 126 passed, 1 skipped. - New pins: - core `effective-object-permissions.test.ts` (15 tests); - plugin-security `get-effective-object-permissions.test.ts` (19 tests). This includes the table-driven parity pin: plain-wildcard subjects × registered objects × every verb, the real `can()` against `checkObjectPermission`; - plugin-hono-server `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: - It was taken at head `403f653799` through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `f8e0efad` → `80ea1874`. - `dist/` was rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. - Observed direction: red. - plugin-security: 7 failed, 12 passed. Every plain-wildcard parity row failed, and the reported case failed. The member and all-false rows stayed green. - core: 6 failed, 9 passed. - hono: 1 failed, 2 passed. - Restore: the blob equals HEAD, `git diff HEAD` is empty, and `git status --porcelain` is clean. After a rebuild the marker is absent from `dist/` 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 `403f653799` - The list comes from `node 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. - **66 of 66 exited 0.** One needed a second run: `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) because 8 unrelated packages had no `dist/`, 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 7b27bd0`: exit 0, 5 citations resolve. - The CI-only families that `dispatch-gates` names 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-wide `pnpm 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. - **Q2 → A.** `Clause-②: yes (widening)` stands, and `@objectstack/core` stays `minor`, on the objectstack-ai#18783 precedent for an added optional member of a published parameter type. - **Q1 → A, done in this PR.** The one commit on top of `403f653799` rewrites 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 diff` shows 1 line removed and 1 added. Nothing else changed, and the branch was not merged with `main`. - **Gates re-run at `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. - exit 0: the `--self-test` of `check-empty-changeset`; `check-changeset-no-major --base origin/main` and its `--self-test` ("This diff introduces no `major` bump"); `check-adr-0087-registration --base origin/main` and its `--self-test` ("2 non-breaking changeset(s) seen"). - exit 0: `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`. - exit 0: `node scripts/check-issue-citations.mjs --base 7b27bd0` (5 citations resolve). - `dispatch-gates --commands` at this head derives the same 66 commands as at `403f653799`. 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. - **The super-user fold over-grants within one set** (walled `organization_admin`: 38 over cells, fixture and showcase alike). `foldWildcardSuperUser` folds the merged `'*'` bypass into every entry, including entries that the super-user set itself names narrower. `resolveObjectPermission` answers that set with its explicit entry. - The map therefore grants create, edit, delete and import on `sys_position`, `sys_permission_set`, `sys_position_permission_set`, `sys_user_permission_set` and `sys_user_position`, and edit on `sys_organization`, where the evaluator refuses. - On the write path, an option gated on `current_user.can('sys_position', 'edit')` is ADMITTED for a walled org admin (measured), while `checkObjectPermission('update', 'sys_position')` is `false`. - The fold's docblock says it is "exactly as broad as real enforcement — never broader". - **The super-user entries never carry the wildcard's own bits.** `can(X, 'transfer')` is `false` for `admin_full_access` and a walled `organization_admin` on every object, where `checkObjectPermission('transfer')` is `true`: 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. - **`apiOperations` ignores `enable.apiEnabled: false`.** An object declaring it with no `apiMethods` is 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. - **DELIBERATE CORRECTION of a pending release note: `.changeset/18783-server-can-option-visibility.md`** (seat ruling Q1 A). That changeset landed with objectstack-ai#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**. Per `landing-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 since `0318faf692` landed). The file writes the object placeholder inside angle brackets; it is written here as `THAT_OBJECT` because the GitHub body sanitizer drops angle-bracket fragments: > **Known gap, not changed here.** `can()` reads only the per-object entries of the map, and `/auth/me/permissions` lists an object for a `'*'` wildcard grant only when that grant carries a super-user bit. So a subject whose access to an object comes only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — gets `false` from `current_user.can('THAT_OBJECT', …)`, although the data plane admits the write. Before this release such a gate was never enforced for anyone; after it, that population is refused on a `can`-gated option. Any client that answers `can()` from the same `/auth/me/permissions` map gets the same `false`. - Head text, at `228724b3f3`: > **Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before objectstack-ai#20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by objectstack-ai#20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. 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`. - Left byte-identical as ruled, and flagged for the reviewer: that changeset's sentence "The `/auth/me/permissions` response is byte-identical for the same resolved sets (measured on five fixtures against the previous build)." It describes objectstack-ai#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. - **Branch base.** The branch is 4 commits behind `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. --------- Co-authored-by: Claude <noreply@anthropic.com>
…user.can() from the security service (objectstack-ai#20082) (objectstack-ai#20138) Fixes objectstack-ai#20082 Clause-②: no (narrowing) A `formula` field and a CEL `defaultValue` that call `current_user.can(object, verb)` now get the acting subject's effective object permissions. That is the map PR objectstack-ai#20079 wired for option visibility. It comes through `ObjectQL.registerEffectiveObjectPermissionsResolver` and formula's `toEvalPermissions`, and it is resolved at most once per engine operation. ## The two sites, base against head Measured one-shot through a real `ObjectQL` with a SQL driver (better-sqlite3 `:memory:`) and a real `SecurityPlugin`. The caller holds `crm_account: allowRead + allowEdit`, so `edit` is the granted verb and `delete` the denied one. Base is `55daf89d74`; head is this branch. The same cells are pinned permanently in `packages/objectql/src/engine-formula-default-permission.test.ts` with a resolver double. | site | granted | denied | no resolver | resolver throws | |:--|:--|:--|:--|:--| | formula field on `find`, `findOne`, insert echo, update echo | base `null`, no log; head `true` | base `null`, no log; head `false` | base `null`, no log; head `null` plus one `warn` per operation, `reason: 'no-permission-source'` | base `null`, no log; head `null` plus one `warn` per operation carrying the error, `reason: 'permission-resolution-failed'` | | CEL `defaultValue` on insert | base unset plus `warn`; head stores `true` | base unset plus `warn`; head stores `false` | unchanged: unset plus the existing `warn`, which carries formula's "carries no permission data" refusal | base unset plus `warn`, row written; head the insert is refused with the resolver's own error, and nothing is written | | a `required` field with that default | base refused `VALIDATION_FAILED` / `required`; head admitted (`true`) | head admitted (`false`) | unchanged: refused `VALIDATION_FAILED` / `required` | head refused with the resolver's own error | At base the resolver was asked 0 times by either site, whether it granted, withheld or threw. That is H1, confirmed. ## The rule each site follows (H3) - **Formula field (a read).** Today a formula that does not evaluate reads `null` and logs nothing: `applyFormulaPlan` assigns `r.ok ? … : null`. A read cannot refuse a row over one computed field, so a `can` formula with no map keeps that `null`. It is no longer silent: the engine logs one `warn` per operation naming the object, the `can` fields and the reason. A throw fails closed. It is never read as "no grants", which would make an empty map answer `false`, and never as a grant. - **CEL default (a write).** Today a default that does not evaluate is left unset with the `warn` "Failed to evaluate default expression". A `required` field so defaulted is then refused by required-validation (measured at base with the real plugin: `VALIDATION_FAILED`, field `d_req`, code `required`). With no resolver that rule is kept byte for byte: the absent member passes NO map. A throw fails closed the way the option gate does. A row whose `can` default needed the map is refused with the resolution's own error, re-raised untouched. Under `insertMany` only that row is refused, and the `validate()` preview rejects the same way. A row that supplies the field is never refused by a resolution it did not need. ## Where the map comes from, and how often (H2) - `permissionResolution(context)` is one lazy, memoised resolver ask per engine operation. It is `undefined`, meaning no map, when no resolver is registered or the operation has no acting user. The same conditions apply to the option gate. - **Read path.** `find` and `findOne` resolve after the driver returns, and only when a planned formula calls `can` and at least one row came back. They use `opCtx.context`, which is the context the security middleware already ran with. So `plugin-security`'s per-context permission-set memo serves the resolution. - **Write path.** One resolution per write is shared by the CEL defaults, the re-default after the static-`readonly` strip, the option gates and the formula fields on the response. `resolveOptionPermissions` now receives the write's resolution instead of calling the resolver itself. When it asks, and what a throw does, are unchanged; its 13-case suite passes as it was. - The "needs the map" test for both sites is `readsPermissionPredicate`, the option gate's own AST reading of a receiver `can` call. It is exported from `rule-validator.ts` so that there is one detector, not two. It is not re-exported from the package entry. Resolver asks per operation (pinned): | operation | base | head | |:--|--:|--:| | `find` over 4 rows with a `can` formula | 0 | 1 | | `findOne`, and a by-id update echo | 0 | 1 each | | insert with a `can` default and a `can` formula echo | 0 | 1 | | insert that picks a `can`-gated option AND defaults a `can` field AND echoes a `can` formula | 1 | 1 | | batch insert of 6 rows | 0 | 1 | | `validate()` over 2 rows | 0 | 1 | | two consecutive `find`s | 0 | 2 (never kept across operations) | | object with no `can` anywhere, even with a throwing resolver | 0 | 0 | | system read (no acting user) | 0 | 0 | ## Declaration (H4) - **Narrowing.** When the resolution fails, an insert whose `can` default needed it is now refused. At base that row was written with the field unset. So the changeset and this body carry `Clause-②: no (narrowing)`, a **BREAKING** banner and `adr-0087: not-required (no-migration-prescription)`. No authored key, stored shape, export or route changes. - **The claim reads `Clause-②: no`.** The arm is added here because the dispatch's H4 says a write whose default now fails closed is a narrowing to declare. The seat owns the claim line. - **Reachability of that path.** In the shipped composition, `SecurityPlugin`'s middleware resolves the same memoised permission sets before the write and already refuses on a resolution failure. The newly refused path is therefore reachable only when `buildEffectiveObjectPermissions` throws, when the map is off-shape, or with a third-party resolver. - **Widening.** No key, export or route is added. A `required` field defaulted by `can` is now admitted where it was always refused. That is a declared default finally evaluating, not a new surface. ## Tests Head is `4fcfbed346`. Each line names the commit it ran at. The only commits after `9e622fbedd` edit the new test file: they type its options objects and make its driver refuse unknown WHERE combinators. - **Base red.** The new pin against `engine.ts` and `rule-validator.ts` restored to `55daf89d74` (blob `a9ec130693` = base blob), then restored to HEAD (blobs `15ca43c2eb` and `90cec7aea7` = HEAD, `git diff HEAD` empty) gave `Tests 27 failed | 3 passed (30)`. For example, "expected null to be true" (granted `find`), "expected [] to have a length of 1 but got +0" (no-resolver warn), and "expected ValidationError: flag is required to be Error: permission store unreachable" (throw on a required default). The 3 that pass are controls that must hold on both sides: no-resolver on a required default, no `can` anywhere, and a system read. This ran at `ca6dea2249`, with 30 cases; the 31st case, the `validate()` rejection, was added after it. - **Head (`4fcfbed346`).** The new pin plus `engine-option-permission-predicate` gave `Test Files 2 passed (2)` and `Tests 44 passed (44)`, which is 31 plus 13. - **Ablation (at `ca6dea2249`; src-resolved, so no build).** I ran `scripts/ablation-replace.mjs` to replace the memo's `pending ??=` with `pending =`. The anchor went 1 to 0, the marker 0 to 1, and the blob went `15ca43c2eb` to `592f152bbd`. The pin then gave `Tests 4 failed | 26 passed (30)`: every "one resolution" cell got 2 or 3 asks. The file was restored with blob == HEAD and `git diff HEAD` empty. - **`@objectstack/objectql`.** `vitest run --project local` gave `Test Files 315 passed (315)` and `Tests 5373 passed (5373)`. It ran at `9e622fbedd`; the only later commit retypes the options objects in the new test file. `--project repo` gave 1 file, 5 tests passed. `typecheck` (src, scripts and the test layer) exited 0 at `4fcfbed346`, and the test layer still holds 40 files and 234 errors in the debt ledger. The new test file is in `tsconfig.test.json`'s program (`--listFiles`) with 0 errors. - **`@objectstack/plugin-security` (`9e622fbedd`).** The full suite, run with `objectql` aliased to source, gave `Test Files 135 passed (135)` and `Tests 2676 passed (2676)`. - **Dogfood (`55ec8feee6`).** On a closure built by turbo (64 tasks), `field-zoo-roundtrip`, `field-zoo-value-shape`, `showcase-static-readonly` (the re-default path), `showcase-fls-read-mask-strip` and `expression-conformance` gave `Test Files 5 passed (5)` and `Tests 109 passed (109)`. - **Targeted neighbours (`9e622fbedd`).** `engine-option-permission-predicate`, `rule-validator.option-visibility`, `engine-write-formula-hydration`, `engine-formula-scale`, `engine-cel-default-temporal-shape`, `engine-default-value-tokens` and `record-title`, run with the new pin, gave `Test Files 8 passed (8)` and `Tests 170 passed (170)`. - **eslint, narrowed (`4fcfbed346`).** `eslint --no-inline-config --format json` on the 3 changed `.ts` files found 3 files, 0 errors and 0 warnings. The population is read from `eslint.config.mjs`, and no file was reported ignored. The config sets no `parserOptions.project`, so no type-aware rule can move a verdict on an untouched file. ## Gates - **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `4fcfbed346` derived 65 commands. - **Run.** All 65 were run at `4fcfbed346`, and all 65 exited 0. - **Reconciliation.** `--ran` reported `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN`. - **Fixed on the way.** Two families were red at `9e622fbedd` and are green at head: - `check:query-options-erasure`: the new test's options objects were erased to `any`, and the test surface grew from 236 to 244 sites. They are now typed, and the surface is back to 236. - `check:where-matcher`: the test driver read an unknown `$` key as a field name. It now refuses it. - **Prerequisite, then measured.** `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET) on the first pass. Later gates in the list built the missing dists, and it exits 0 at head. A CJS/ESM load probe of `packages/objectql/dist` sees `ObjectQL` and `evaluateFormulaField` on both. - **`check-changeset-no-major`, fed this body as a `pull_request` payload (`--event`).** It reported `LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch`. - **`check-adr-0087-registration`.** It reported `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition` and `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. - **`check-issue-citations --base 55daf89`.** Exit 0: `17 resolves`. - **Left to CI.** The derivation names 5 path-scheduled CI jobs and 4 type-check lanes. They are CI's own runs, and they are NOT MEASURED locally. ## Acceptance notes - `carrier:` 承接者:无. The expression-conformance ledger row `cel-formula` declares `failPolicy: 'fail-soft-log'`, but a formula that faults for any other reason still reads `null` with no log line (`applyFormulaPlan`'s `r.ok ? … : null`). This PR logs only the `can` case, which is its own. This is read off the code, not measured at a public door. - `carrier:` 承接者:无. `evaluateFormulaField` and `resolveRecordTitle` are synchronous and hold no resolver, so a title formula calling `can` still yields `null` there. The docblock and the changeset say so. - `carrier:` 承接者:无. Each `expand` of a related object is its own `find`, so it asks the resolver again. `plugin-security`'s per-context memo absorbs the set resolution; the map itself is rebuilt. - `carrier:` 承接者:无. A system read, which has no acting user, of a `can` formula reads `null` with no warn, as any `current_user` formula does with no subject. ## Deviations from the claim's file surface - `packages/objectql/src/validation/rule-validator.ts` gets `export` on `readsPermissionPredicate`, plus a three-line docblock note, so that there is one `can` detector. - In `engine.ts`, beyond the bodies of `applyFormulaPlan` and `applyFieldDefaults`: - their call sites in `find`, `findOne`, `insert`, `update` and `validate`; - `hydrateWriteFormulas`, which is now async and takes a permissions callback; - four private helpers; - `resolveOptionPermissions`, which now takes the shared resolution. H2 requires that for "at most once per write" across both uses. Its behaviour is unchanged. - `packages/core`, `packages/formula` and PR objectstack-ai#20117's `aggregate` region are untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… — no cell checkObjectPermission refuses (objectstack-ai#20136) (objectstack-ai#20165) Fixes objectstack-ai#20136 Clause-②: no The effective object-permission map (`buildEffectiveObjectPermissions` in `@objectstack/core`: the `objects` slot of `GET /auth/me/permissions`, and what `ISecurityService.getEffectiveObjectPermissions` hands to `current_user.can()` on the write path) no longer grants any cell that `PermissionEvaluator.checkObjectPermission` refuses, for every super-user shape measured. This completes the family's closing assertion: 0 over-granted and 0 under-granted cells over every super-user subject in the parity table. ## What was wrong Before its per-set super-user fold, the builder ran `foldWildcardSuperUser` over the MERGED map. That pass put the merged bypass bits on every entry: - into an entry the super-user set names ITSELF, where `resolveObjectPermission` answers that set with its explicit entry (the walled `organization_admin`'s read-only RBAC rows and write-denied identity tables, an explicit `{}` entry, an export-only entry); - `allowCreate` on `modifyAllRecords` alone, which the spec's `objectPermissionGrants` gives no create cell. Mechanism hypotheses H3 are both CONFIRMED, by reading and by measurement: removing that pass alone takes every row below to 0, and adds 0 under-granted cells anywhere. ## What changes `packages/core/src/security/effective-object-permissions.ts`: - `buildEffectiveObjectPermissions` no longer calls `foldWildcardSuperUser`. The super-user fold is `foldSuperUserWildcardGrants` alone (added by objectstack-ai#20134): per set, onto the entries that set does not name, exactly the bits `objectPermissionGrants` says that set's wildcard grants. Another set's super-user wildcard still widens an entry bit by bit, as `checkObjectPermission` combines sets. The seed, plain-wildcard coverage, clamp and annotation are untouched. - `foldWildcardSuperUser` keeps its name, signature and body. It is a released export of `@objectstack/plugin-hono-server` (re-exported from core). Its docblock now says it is not a step of the map, and names where it is broader (own narrower entry, create) and narrower (`transfer`, a super-user wildcard's own plain bits) than the server. Retiring it is an export removal, so it is left as an open question below rather than done here. - Docblocks that named the merged fold as a map step (`wildcardGrantsSuperRead`, the seed, plain coverage, the clamp, the builder) now name the per-set fold. Tests: - `plugin-security` `get-effective-object-permissions.test.ts`: `KNOWN_OVER_GRANT`, its flip-trigger assertion and its "names a row" test are DELETED rather than emptied. The table now holds every subject EXACT in both directions, with the managed-write clamp's narrowing as its only allowance. Each enumeration shape is a named subject, tagged with its row (`[objectstack-ai#20136 row a]` to `[objectstack-ai#20136 row h]`, plus the existing one-set row), so a regression names its row. Rows b, d, e, f, g and h are new. The `apiOperations` column runs over them too, which is where row e's offered-but-refused `export` is held. One spelled-out case: the walled org admin may not create, edit or delete `sys_position`, and `can()` says so, with and without `member_default`; `sys_user` edit without it. - `core` `effective-object-permissions.test.ts`: a `[objectstack-ai#20136]` block (own read-only entry, `{}` entry, export-only entry under a super-read wildcard, `modifyAllRecords` without create, another set's wildcard still widening). Two composition fixtures that relied on the over-grant are re-spelled: the clamp and fold cases now put the narrower entry in a SECOND set, so they still exercise the fold. - `plugin-hono-server`: the endpoint fixture's `sys_member` deny moves to the second set, so its "clamp over the fold" assertion keeps exercising the fold. There is a new endpoint case, a super-user set's own narrower entry served as that set's answer, byte-equal to the builder. `fold-wildcard-superuser.test.ts` gets a note that it pins the standalone released helper. Changesets: `.changeset/20136-super-user-fold-per-set.md` (`@objectstack/core` patch), plus three one-clause DELIBERATE CORRECTIONS of pending notes, listed below. ## Measurement The map answer is the real formula `current_user.can()` over `toEvalPermissions(map)`. It is compared with `PermissionEvaluator.checkObjectPermission` on all 10 verbs. `over` means the map grants a cell the evaluator refuses, and `under` the reverse. As in the existing pin, create, edit and delete refused on a guarded managed object is the managed-write clamp and is not counted. The fixture has 61 registered objects: every object schema exported by `@objectstack/platform-objects` and by plugin-security's `objects`, plus 4 app objects (public, `apiMethods`-restricted, private, and `apiEnabled: false`). - Base: `49144fccc8`, with core's `dist/` built from it. - Head: `6527062a22`. - A second base leg (the producer file restored from base, core rebuilt, the marker proven present in `dist/`) is byte-identical (sha256) to true base on all 27 subjects. ### Enumeration (over-granted cells, base to head) | row | shape | base | head | |---|---|---|---| | a | walled `organization_admin` + `member_default` | 38 | **0** | | b | walled `organization_admin` alone | 44 | **0** | | c | `'*': { modifyAllRecords: true }` alone | 32 | **0** | | d | `{}` explicit entry inside a super-user set | 8 | **0** | | e | `'*': { viewAllRecords }` over its own `{ allowExport: true }` entry | 2 (+1 `export` offered and refused) | **0** (+0) | | f (new) | `'*': { viewAllRecords }` over its own narrower entry | 1 | **0** | | g (new) | `modifyAllRecords`-only `'*'` beside a set naming the object narrower | 2 | **0** | | h (new, control) | `admin_full_access` + `organization_admin` + `member_default` | 0 | 0 | | existing | one set: a super-user `'*'` and a narrower `crm_account` | 7 | **0** | - Rows a, b and d reproduce the card's 38, 44 and 8 exactly. - Row c is 32 here and 36 on the card: it is `create` + `import` on every object the clamp does not narrow, 16 on this fixture and 18 on the card's 63-object fixture. - Row f: the super-read path over the set's own entry, with no `modifyAllRecords` and no export. - Row g: the create lift arriving from a SECOND set's wildcard, over an entry the first set names. - Row h: the control row, where another set's super-user wildcard legitimately widens a narrower entry. ### Parity (all 27 subjects) The 18 subjects of the objectstack-ai#20083/objectstack-ai#20134/objectstack-ai#20135 table, rows a to h, a super-read wildcard alone, `admin_full_access` alone, and a super-read wildcard beside a plain one: - **over**: 8 subjects were non-zero at base (the table above). Every subject is **0** at head. - **under**: **0** at base and at head, in every subject. No cell `checkObjectPermission` grants became refused in the map. - **`apiOperations` column** (offered and refused, and served but hidden): row e was 1 offered-and-refused at base (`crm_account` `export`). It is **0** at head, and every other cell is 0 at both. ### Reach, at a public door (H2) The write path is PR objectstack-ai#20079's `ObjectQL` write path: the real engine, with the real `SecurityPlugin` resolver (the function it registers on the engine) as its resolver. `crm_case.stage` has options gated on `current_user.can(…)`. | subject | gate | base | head | `checkObjectPermission` | |---|---|---|---|---| | walled org admin + `member_default` | `can('sys_position','edit')` | **ADMITTED** | refused `VALIDATION_FAILED` / `invalid_option` | false | | walled org admin + `member_default` | `can('sys_position','create')` | **ADMITTED** | refused | false | | walled org admin alone | `can('sys_position','edit')`, `can('sys_position','create')` | **ADMITTED** | refused | false | | walled org admin alone | `can('sys_user','edit')` | **ADMITTED** | refused | false | | `'*': { modifyAllRecords }` | `can('sys_position','create')`, `can('crm_account','create')` | **ADMITTED** | refused | false | | the other 18 cells of the 25: the controls `admin_full_access` + `member_default` and `member_default`, and every cell the server grants | | unchanged | unchanged | agrees | `GET /api/v1/auth/me/permissions` was served by the real `registerCurrentUserEndpoints` on a Hono app over the same resolved sets and registry. | subject | `objects.sys_position` create / edit / delete | response bytes | `objects` sha256 | |---|---|---|---| | walled org admin + `member_default` | true / true / true → **false / false / false** | 13187 → 13203 | `7d78b7069a14` → `7c41ba445efa` | | walled org admin alone | true / true / true → **false / false / false** | 12789 → 12807 | `6cf54b268f58` → `65a3ee709036` | | `'*': { modifyAllRecords }` | true / true / true → **false** / true / true | 11122 → 11138 | `87f9b1e48f79` → `e5a31ecba130` | | `admin_full_access` + `member_default` | unchanged | 12987 → 12987 | identical | | `member_default` | (no entry) | 6704 → 6704 | identical | ### Collateral (H4) - **19 of 27 subjects are byte-identical (sha256)**: every subject holding no super-user wildcard, and every super-user subject with no own narrower entry and no create-less `modifyAllRecords`. That includes `admin_full_access` alone and beside `member_default`, `organization_admin_no_bypass`, and row h. - **The 8 changed subjects**: no entry is added or removed, and no bit turns `false` to `true`. Only `true` to `false`: | subject | bits turned `true` → `false` | |---|---| | row a | `allowEdit` ×6, `allowCreate` ×5, `allowDelete` ×5 | | row b | `allowEdit` ×8, `allowCreate` ×5, `allowDelete` ×5 | | row c | `allowCreate` ×16 | | row d | `allowRead`, `allowCreate`, `allowEdit`, `allowDelete` ×1 each | | row e | `allowRead` ×1 | | row f | `allowRead` ×1 | | row g | `allowCreate` ×1 | | one-set row | `allowCreate`, `allowEdit`, `allowDelete` ×1 each | - **One `apiOperations` change**: row e's `crm_account` goes from no annotation (default-allow, which offered `export`) to the closure without `export`. - **Enforcement untouched**: `permission-evaluator.ts` and all of plugin-security's non-test source have 0 changed lines, so no `checkObjectPermission` answer changes. ### Reverse verification The fix was committed first. The mutate leg restored the producer file from base (`git restore --source=49144fccc8`), rebuilt core, and proved the marker live with `ablation-dist-preflight` (present in 2 built files). On that tree: - core went red on 7 of 32: the 5 new `[objectstack-ai#20136]` cases and the 2 re-spelled composition cases; - plugin-security went red on 10 of 64: rows a, b, c, d, e, f, g and the one-set row, the spelled-out case, and row e's `apiOperations` column; - hono-server went red on 1 of 47: the new endpoint case. Row h and every other subject stayed green. The direction is the expected one (to red). The restore used `git checkout HEAD --`, proven by the file's hash equalling its HEAD blob and by an empty `git diff HEAD`. Core was then rebuilt, with the marker proven absent from `dist/`. ## Tests and gates, at `6527062a22` - `pnpm --filter @objectstack/core test`: 53 files, 1358 passed. `test:repo`: 3 files, 48 passed. - `pnpm --filter @objectstack/plugin-security test`: 135 files, 2730 passed. - `pnpm --filter @objectstack/plugin-hono-server test`: 27 files, 324 passed. - `typecheck` for core, plugin-security and plugin-hono-server (`tsc`, the scripts/typecheck projects, `check:test-typecheck` over the test layer): exit 0. - Access-security dogfood, over the dogfood closure built at head: the 11 `access-security.json` checklist dogfood files plus `organization-update-door.dogfood.test.ts`, 12 files, 165 passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 64 commands from this diff. All 64 were run, and `--ran` reconciles 64 of 64 with 0 NOT-MEASURED. - 63 exit 0. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (8 unrelated packages had no `dist/`). After building them it exited 0. - **`check-empty-changeset --base origin/main` exits 1, by design**: it is the DELIBERATE CORRECTION class below, and the gate stays red until a person confirms. - `node scripts/check-issue-citations.mjs --base 49144fc`: exit 0, 8 citations resolve. - `eslint --no-inline-config` on the 5 changed TS files: 0 errors, 0 warnings, 5 files counted from `--format json`. All 5 are in the config's population (`--print-config` resolves each). The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. ## Deliberate corrections of pending release notes, for confirmation `check-empty-changeset` is red on these three by design. Each is one clause, and each sentence was made false by this PR: - `.changeset/18783-server-can-option-visibility.md`: "The map still differs from `checkObjectPermission` … an entry the super-user set itself names narrower can read as granted." now reads: "The map also differed … read as granted, which is closed in this same release (`.changeset/20136-super-user-fold-per-set.md`)." - `.changeset/20134-super-user-entries-every-bit.md`: "Both over-grants are left exactly as they were." now reads: "…left exactly as they were by this change, and both are closed in this same release (`.changeset/20136-…`)." - `.changeset/18931-me-permissions-unrestricted-export-annotation.md`: "Its CRUD bits are folded `true`" now reads: "Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets)". The note's population includes a bare `'*': { modifyAllRecords }`, which no longer reads `create`. No released note was touched. ## Cross-lane For super-user subjects, two things change: the bytes `/auth/me/permissions` serves, and the write path's `current_user.can()` answer. Both change only on the cells listed above, and both move toward what the server enforces. For the seat to relay to `domain:cli` and `domain:services` at landing. The REST door, `checkObjectPermission` and `annotateEffectiveApiOperations` are untouched. ## Open question `foldWildcardSuperUser` is still exported, but nothing in this repository now calls it outside its own pins. Over a merged map it cannot be made to match the server, because the map has lost which set named which object. Retiring it removes a released export of `@objectstack/plugin-hono-server`, which is a breaking change (`Clause-②: yes (narrowing)`). That is a decision for the seat or the maintainer, not a rider on this fix. Recommendation: retire it in its own change, because an exported helper documented as the map's fold is how the merged reading could come back. ## Acceptance notes - H1: every row a to e was re-measured and reproduced (row c's count differs only by fixture size, as explained above). Three rows were added: f, g, and h (control). No other super-user shape measured diverges at base. - H5: no released behaviour becomes refused. The write-path `can()` gate is PR objectstack-ai#20079's, whose changeset is still pending. On `/auth/me/permissions` only map bits narrow, and the server's answer to every request is unchanged. - The suggested route turned `KNOWN_OVER_GRANT` into an empty set. This PR deletes it instead: an empty allowance ledger is a mechanism for re-admitting a divergence, and the family's assertion is exact. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18783
Clause-②: yes (widening)
Executes ruling A on the card (maintainer 「同意」, comment 5725678115): the census comes first, then the reader, then the wiring. The server now answers
current_user.can(object, verb)in an option'svisibleWhen. Before this PR the predicate faulted on every authenticated write and the value was admitted unenforced. The interface member is the one PR #19622 declared:ISecurityService.getEffectiveObjectPermissions?(context?).packages/specis not touched.Declared cross-lane edits.
packages/plugins/plugin-securityisdomain:servicessurface; the ruling places it on this card.packages/coreandpackages/plugins/plugin-hono-serverare outside the claim's file surface. They are touched because the dispatch's H2 requires the/auth/me/permissionsroute and the member to read ONE function, and@objectstack/coreis the only package both already depend on (plugin-hono-server must not take a runtime dependency on plugin-security).What lands
registerEffectiveObjectPermissionsResolver(fn). It is the same shape asregisterWriteGateProbe, which the security plugin already registers on the engine.resolveOptionPermissionsasks the resolver at most ONCE per write: a batch insert, a by-id update, an N-row bulk update or avalidate()preview.visibleWhencallscan.toEvalPermissions. A resolution throw, or a map that is not the published shape, is re-raised untouched for exactly the payloads that needed the map: the write fails CLOSED.evaluateValidationRulestakespermissionsand passes it to the per-optionvisibleWhenevaluation.optionVisibilityReadsPermissionsanswers "does this write need the map". It reads the parsed CEL AST for a receiver call namedcan, and uses the same picker (pickedGatedOptions) the evaluator judges with.undefinedstays "no permission data": the predicate is loudly unevaluable and the existing fail-open branch admits the value with a warn that names the missing input.getEffectiveObjectPermissionson the class and on the registeredsecurityliteral, and registers the same method on the engine.resolvePermissionSetsForContextand builds the map withbuildEffectiveObjectPermissionsover the plugin's engine. It freezes the map at the top level.{}.warnat start.buildEffectiveObjectPermissions: the most-permissive merge, thenseedSuperUserRestrictedObjects,foldWildcardSuperUser,clampManagedObjectWritesandannotateEffectiveApiOperations. The four folds andManagedSchemaLike/ApiExposureSchemaLikemoved here unchanged (reindented) from plugin-hono-server./auth/me/permissionsbuilds itsobjectsslot withbuildEffectiveObjectPermissions. The six moved names are re-exported from@objectstack/core, so the package root still exports them. ESM probe: the re-exportedfoldWildcardSuperUseris the same function object as core's (===).Census — every in-repo evaluation site that binds
current_userThe grep, over non-test source outside
packages/formula(the engine itself) andpackages/qa:It gives 24 lines, 18 of them code (6 are comments). Completeness control: a token scan for
ExpressionEngine|celEngine|templateEngine|resolveSeedRecord|resolveSeed|buildScope|getEngineover the same tree gives 24 files. Every file outside the grep's population mentions the tokens only in comments, or uses an unrelatedgetEngine. Positive control: the grep's population contains the site the ruling names (rule-validator.tsevaluateOptionVisibility).current_usercantodayobjectql/src/validation/rule-validator.ts:evaluateOptionVisibility(reached from 5 engine call sites: insert, by-id update, bulk update twice,validate())buildEvalUser)objectql/src/engine.ts:applyFormulaPlan(formula virtual fields, read and write-back){id, positions})nullon read and on the insert echo, with NO log (measured at this head with a resolver registered)objectql/src/engine.ts:applyFieldDefaults(CELdefaultValue){id, positions})Failed to evaluate default expressionnames the missing input (measured)metadata-protocol/src/seed-loader.ts:resolveSeedRecord{ id: null })errored++, an actionable error)plugin-security/src/rls-compiler.ts:compileExpressionOutcome(compileCelToFilter,current_useras a lowering variable)unsupported method "can()": the policy drops and RLS denies (fail closed)lint/src/validate-rls-predicate-enforceability.tscondition(hook-wrappers.ts),readonlyWhen,requiredWhenx2,script,conditional(rule-validator.ts), approvals expression approver, share-link eligibility, flowcelScopex2, sharing-rule seeder, lint sharing gatecurrent_useris not bound thereH1 — red on the base head, green here
Ruling pin,
rule-validator.option-visibility.test.ts, run on the base (2274894cc) before the fix:REFUSES a can-gated option … withholds the verbfailed withexpected undefined to be an instance of ValidationError(admitted).ADMITS it …failed withexpected [ { …(2) } ] to have a length of +0 but got 1(the fail-open warn).Tests 7 failed | 31 passed (38). The no-cancontrol and the no-permission-data case were green on the base, which is their point.With the fix:
Tests 38 passed (38).H2 — the producer, and byte-equality
The
/auth/me/permissionsmerge lived inline in plugin-hono-server'scurrent-user-endpoints.ts. It is nowbuildEffectiveObjectPermissions, read by both the route and the member.equal=true.current-user-endpoints-effective-objects.test.tspins that the route'sobjectsequalsbuildEffectiveObjectPermissionsover the resolved sets, byte for byte.get-effective-object-permissions.test.tspins that the member equals the same function overresolvePermissionSetsForContext, for three subjects. Both fixtures make the seed, fold, clamp and annotate steps all fire.H3 — resolutions per write
canmakes 0 resolutions, even with a resolver that would throw. A system write makes 0.The set resolution under the member is plugin-security's existing per-context memo, keyed on the request's context object and retired by the write epoch. Within one request context a second ask costs no second set load. A new context loads again (pinned).
Both failure directions (pinned)
code,statusintact), it is not aValidationError, and nothing is written.{ crm_account: true }):TypeErrorfromtoEvalPermissions, nothing written.meta.error.messagecontainscarries no permission data. It is not denied.Ablation: the insert call site's
permissions: insertPermissionsFor(rows[i])was replaced bypermissions: undefined /* ABLATION-18783 */throughscripts/ablation-replace.mjs. The anchor went 1 to 0, the marker 0 to 1, and the blob changed.git diff HEADwas empty.Known gap — measured, not changed here
can()reads only per-object entries./auth/me/permissionsmaterialises an entry for an object covered by'*'only when that wildcard carries a super-user bit (the seed pass). With the shippedorganization_admin_no_bypassplusmember_default(the grant a deployment without an organization wall gives organization owners and admins):crm_accountentry;current_user.can('crm_account', 'edit')evaluates tofalse;PermissionEvaluator.checkObjectPermission('update', 'crm_account', sets)istrue.admin_full_accessand walledorganization_adminanswertrueon both sides. Before this PR that population'scangate was never enforced for anyone. From this PR on, the server refuses them on acan-gated option. The fix belongs to the map's producer (materialising plain-wildcard coverage, which changes the/auth/me/permissionsresponse) or to formula'scan. It is raised as a question in the report, not decided here.Overlap with in-flight work
rule-validator.ts: this diff touches theEvaluateRulesOptionsinterface (one new member), theUSER_SCOPE_ROOTSdocblock, theevaluateOptionVisibilityregion (a new picker plus helpers), and ONE line insideevaluateValidationRules's body: theevaluateOptionVisibility(...)call gainsopts.permissions. That call is not the function's head, therequiredWhen/readonlyWhenarms orunevaluableRuleError, which are draft PR #20028's region.traversalRefusal(PR #20049) is already onmainand was merged in here without a conflict.Verification (at
f3fe6d6cfd)test312 files / 5292 tests andtest:repo1/5; plugin-security 134 / 2658; plugin-hono-server 27 / 313 (+1 todo); coretest53 / 1331 andtest:repo3 / 48. All passed. The objectql, plugin-hono-server and core runs are from the merge commitb5416a2b49; the two later commits touch only plugin-security's new test file, and plugin-security's suite and typecheck were re-run atf3fe6d6cfd.typecheckpassed for core, objectql, plugin-security and plugin-hono-server. Each new test file is in a tsc program (--listFiles, viatsconfig.test.jsonor the main config).check-dts-emittedpresent). CJS and ESM load probes see the new core exports and the hono re-exports.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 72 commands, and all were run atf3fe6d6cfd. 69 exited 0.check:dual-build-cjs-loads,check:type-check-debt(both need the whole workspace built) andcheck:i18n(needs the CLI build closure).--ranreconciliation: 72 derived, 69 run, 3 NOT-MEASURED, 0 UNRUN.check-issue-citations --base b76aad5f6: every citation this change adds resolves.--no-inline-config,--format json) on the 11 changed.tsfiles: 11 files, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so the diff cannot move a verdict on an untouched file.Acceptance notes
fold-wildcard-superuser.test.ts,effective-api-operations.test.ts) stay where they are. They exercise the folds through that package's unchanged re-exports. core carries its own composition pins.objects: {}(its pre-existing.catch(() => [])). The member's stance differs on purpose: it throws.check-changeset-no-major's Clause-② level axis reports NOT APPLICABLE locally (there is nopull_requestpayload). CI reads it.Seat amendment (5825601266)
Clause-②is corrected from the claim'snotoyes (widening). The diff adds public exports to@objectstack/core(buildEffectiveObjectPermissions, plus the folds and types moved fromplugin-hono-server) and a publicObjectQL.registerEffectiveObjectPermissionsResolver.packages/core, andplugin-hono-server, which isdomain:clisurface) is accepted as the single-producer consequence of the member's "computed once" rule.Fixes; the value-expression rows are filed as their own card).canis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 is re-graded to p1 per ruling A's census clause.Patch round 1 (merge-queue failure,
7a6b091b2a)Test Core (1/6)/Spec property liveness(queue run 36088336600). The spec liveness gate reportedpermission/objects.allowExportUNANCHORED, because its evidence citedplugin-hono-server/src/current-user-endpoints.ts, which, after this PR movedannotateEffectiveApiOperationsinto@objectstack/core, no longer namesallowExport(0 mentions; the new file has 7). PR CI runs only the affected subset, and the queue runs the full suite.packages/spec/liveness/permission.json. The citation is repointed topackages/core/src/security/effective-object-permissions.ts#annotateEffectiveApiOperations, withverifiedAt2026-09-25 and a dated note. It is a declared cross-lane pointer update in the spec LEDGER, not insrc.check:livenesswent from exit 1 (1 UNANCHORED) to exit 0.check-liveness.test.tswent from 20 failed / 44 passed to 64 passed.Generated by Claude Code