Skip to content

feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen - #20079

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-18783-server-can-permissions
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-18783-server-can-permissions

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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's visibleWhen. 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/spec is not touched.

Declared cross-lane edits. packages/plugins/plugin-security is domain:services surface; the ruling places it on this card. packages/core and packages/plugins/plugin-hono-server are outside the claim's file surface. They are touched because the dispatch's H2 requires the /auth/me/permissions route and the member to read ONE function, and @objectstack/core is the only package both already depend on (plugin-hono-server must not take a runtime dependency on plugin-security).

What lands

  • objectql gets a new engine seam, registerEffectiveObjectPermissionsResolver(fn). It is the same shape as registerWriteGateProbe, which the security plugin already registers on the engine.
    • resolveOptionPermissions asks the resolver at most ONCE per write: a batch insert, a by-id update, an N-row bulk update or a validate() preview.
    • It asks only when three things hold: a resolver is registered, the write has an acting user, and some payload picks an option whose visibleWhen calls can.
    • The answer goes through formula's 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.
  • rule-validator: evaluateValidationRules takes permissions and passes it to the per-option visibleWhen evaluation.
    • optionVisibilityReadsPermissions answers "does this write need the map". It reads the parsed CEL AST for a receiver call named can, and uses the same picker (pickedGatedOptions) the evaluator judges with.
    • undefined stays "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.
  • plugin-security implements getEffectiveObjectPermissions on the class and on the registered security literal, and registers the same method on the engine.
    • The member resolves the sets with resolvePermissionSetsForContext and builds the map with buildEffectiveObjectPermissions over the plugin's engine. It freezes the map at the top level.
    • A resolution failure propagates untouched and never becomes {}.
    • An engine without the seam gets one warn at start.
  • core gets buildEffectiveObjectPermissions: the most-permissive merge, then seedSuperUserRestrictedObjects, foldWildcardSuperUser, clampManagedObjectWrites and annotateEffectiveApiOperations. The four folds and ManagedSchemaLike / ApiExposureSchemaLike moved here unchanged (reindented) from plugin-hono-server.
  • plugin-hono-server: /auth/me/permissions builds its objects slot with buildEffectiveObjectPermissions. The six moved names are re-exported from @objectstack/core, so the package root still exports them. ESM probe: the re-exported foldWildcardSuperUser is the same function object as core's (===).

Census — every in-repo evaluation site that binds current_user

The grep, over non-test source outside packages/formula (the engine itself) and packages/qa:

git grep -n -E "\b(ExpressionEngine|celEngine|templateEngine)\.evaluate\b|\bresolveSeed(Record)?\(|\bcompileCelToFilter\(" -- 'packages/**/*.ts' ':!**/*.test.ts' ':!**/dist/**' ':!packages/formula/**' ':!packages/qa/**'

It gives 24 lines, 18 of them code (6 are comments). Completeness control: a token scan for ExpressionEngine|celEngine|templateEngine|resolveSeedRecord|resolveSeed|buildScope|getEngine over the same tree gives 24 files. Every file outside the grep's population mentions the tokens only in comments, or uses an unrelated getEngine. Positive control: the grep's population contains the site the ruling names (rule-validator.ts evaluateOptionVisibility).

site (file : symbol) binds current_user author-reachable can today verdict
objectql/src/validation/rule-validator.ts : evaluateOptionVisibility (reached from 5 engine call sites: insert, by-id update, bulk update twice, validate()) yes (buildEvalUser) yes. It faulted with "carries no permission data" and the fail-open branch ADMITTED the value (measured on the base head, below). This is a live violation and, by the ruling's own words, the p1 trigger. wired here
objectql/src/engine.ts : applyFormulaPlan (formula virtual fields, read and write-back) yes (own {id, positions}) yes: value null on read and on the insert echo, with NO log (measured at this head with a resolver registered) out of this card's plumbing: a value expression, not a predicate, and it builds its own user object. Reported as a finding.
objectql/src/engine.ts : applyFieldDefaults (CEL defaultValue) yes (own {id, positions}) yes: field left unset, and Failed to evaluate default expression names the missing input (measured) out of this card's plumbing, same reason. Reported as a finding.
metadata-protocol/src/seed-loader.ts : resolveSeedRecord yes (seed identity, or { id: null }) the record is dropped loudly (errored++, an actionable error) out of scope: boot-time replay with no request and no acting subject whose grants would mean anything. The loud refusal is the correct answer.
plugin-security/src/rls-compiler.ts : compileExpressionOutcome (compileCelToFilter, current_user as a lowering variable) as a filter variable unsupported method "can()": the policy drops and RLS denies (fail closed) out of scope: an RLS predicate is lowered into a driver filter, and a filter cannot consult a permission map
lint/src/validate-rls-predicate-enforceability.ts probe variable authoring gate, not a runtime evaluation out of the population
hook condition (hook-wrappers.ts), readonlyWhen, requiredWhen x2, script, conditional (rule-validator.ts), approvals expression approver, share-link eligibility, flow celScope x2, sharing-rule seeder, lint sharing gate no n/a outside the census: current_user is not bound there

H1 — 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 verb failed with expected undefined to be an instance of ValidationError (admitted).
  • ADMITS it … failed with expected [ { …(2) } ] to have a length of +0 but got 1 (the fail-open warn).
  • The run ended Tests 7 failed | 31 passed (38). The no-can control 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/permissions merge lived inline in plugin-hono-server's current-user-endpoints.ts. It is now buildEffectiveObjectPermissions, read by both the route and the member.

  • Route unchanged. Whole response bodies from the base route and this head's route, for the same resolved sets and schemas, are byte-identical on five fixtures (super-user without export, super-user with export, wall-less org admin, plain rep, nothing). Measured one-shot: lengths 1461/381/633/336/168, all equal=true.
  • Permanent pins, both halves. current-user-endpoints-effective-objects.test.ts pins that the route's objects equals buildEffectiveObjectPermissions over the resolved sets, byte for byte. get-effective-object-permissions.test.ts pins that the member equals the same function over resolvePermissionSetsForContext, for three subjects. Both fixtures make the seed, fold, clamp and annotate steps all fire.

H3 — resolutions per write

  • Bulk update across N matched rows: 1 resolution for N=1 and for N=25.
  • Batch insert of 7 rows: 1 resolution.
  • Two writes: 2 resolutions, and a grant revoked between them is refused on the second, so nothing is kept across writes.
  • A write whose gates never call can makes 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)

  • Resolver throws: the insert rejects with that very error object (code, status intact), it is not a ValidationError, and nothing is written.
  • Off-shape map ({ crm_account: true }): TypeError from toEvalPermissions, nothing written.
  • No resolver: admitted, with one warn whose meta.error.message contains carries no permission data. It is not denied.

Ablation: the insert call site's permissions: insertPermissionsFor(rows[i]) was replaced by permissions: undefined /* ABLATION-18783 */ through scripts/ablation-replace.mjs. The anchor went 1 to 0, the marker 0 to 1, and the blob changed.

  • 5 engine cases went red (refuse, admit, throw fails closed, off-shape map, revocation); the 8 control and non-insert cases stayed green.
  • The file was restored to its HEAD blob and git diff HEAD was empty.
  • The first attempt used a replacement already present 178 times. The tool refused it (count unchanged) and restored, so it was a no-op and was re-run with the marker.

Known gap — measured, not changed here

can() reads only per-object entries. /auth/me/permissions materialises an entry for an object covered by '*' only when that wildcard carries a super-user bit (the seed pass). With the shipped organization_admin_no_bypass plus member_default (the grant a deployment without an organization wall gives organization owners and admins):

  • the map has no crm_account entry;
  • current_user.can('crm_account', 'edit') evaluates to false;
  • PermissionEvaluator.checkObjectPermission('update', 'crm_account', sets) is true.

admin_full_access and walled organization_admin answer true on both sides. Before this PR that population's can gate was never enforced for anyone. From this PR on, the server refuses them on a can-gated option. The fix belongs to the map's producer (materialising plain-wildcard coverage, which changes the /auth/me/permissions response) or to formula's can. It is raised as a question in the report, not decided here.

Overlap with in-flight work

rule-validator.ts: this diff touches the EvaluateRulesOptions interface (one new member), the USER_SCOPE_ROOTS docblock, the evaluateOptionVisibility region (a new picker plus helpers), and ONE line inside evaluateValidationRules's body: the evaluateOptionVisibility(...) call gains opts.permissions. That call is not the function's head, the requiredWhen / readonlyWhen arms or unevaluableRuleError, which are draft PR #20028's region. traversalRefusal (PR #20049) is already on main and was merged in here without a conflict.

Verification (at f3fe6d6cfd)

  • Suites: objectql test 312 files / 5292 tests and test:repo 1/5; plugin-security 134 / 2658; plugin-hono-server 27 / 313 (+1 todo); core test 53 / 1331 and test:repo 3 / 48. All passed. The objectql, plugin-hono-server and core runs are from the merge commit b5416a2b49; the two later commits touch only plugin-security's new test file, and plugin-security's suite and typecheck were re-run at f3fe6d6cfd.
  • typecheck passed for core, objectql, plugin-security and plugin-hono-server. Each new test file is in a tsc program (--listFiles, via tsconfig.test.json or the main config).
  • The four packages build (check-dts-emitted present). CJS and ESM load probes see the new core exports and the hono re-exports.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 72 commands, and all were run at f3fe6d6cfd. 69 exited 0.
  • 3 exited 3 with PREREQUISITE NOT MET, so they are NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt (both need the whole workspace built) and check:i18n (needs the CLI build closure). --ran reconciliation: 72 derived, 69 run, 3 NOT-MEASURED, 0 UNRUN.
  • check-issue-citations --base b76aad5f6: every citation this change adds resolves.
  • Narrowed eslint (--no-inline-config, --format json) on the 11 changed .ts files: 11 files, 0 errors, 0 warnings. eslint.config.mjs enables no type-aware linting (no parserOptions.project), so the diff cannot move a verdict on an untouched file.

Acceptance notes

  • plugin-hono-server's pin batteries for the four folds (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.
  • The route's failure stance is unchanged: a set-resolution failure still answers 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 no pull_request payload). CI reads it.

Seat amendment (5825601266)

Patch round 1 (merge-queue failure, 7a6b091b2a)

  • What failed: the queue removed this PR on Test Core (1/6) / Spec property liveness (queue run 36088336600). The spec liveness gate reported permission/objects.allowExport UNANCHORED, because its evidence cited plugin-hono-server/src/current-user-endpoints.ts, which, after this PR moved annotateEffectiveApiOperations into @objectstack/core, no longer names allowExport (0 mentions; the new file has 7). PR CI runs only the affected subset, and the queue runs the full suite.
  • Fix: one ledger file, packages/spec/liveness/permission.json. The citation is repointed to packages/core/src/security/effective-object-permissions.ts#annotateEffectiveApiOperations, with verifiedAt 2026-09-25 and a dated note. It is a declared cross-lane pointer update in the spec LEDGER, not in src.
    • check:liveness went from exit 1 (1 UNANCHORED) to exit 0.
    • check-liveness.test.ts went from 20 failed / 44 passed to 64 passed.
    • The sweep for other pointers made false by the move found none. The two governed ADR mentions (0103, 0124) are still true and were not edited.
  • The delta contract review is recorded against this head.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/core, @objectstack/objectql, @objectstack/plugin-hono-server, @objectstack/plugin-security, @objectstack/spec, touching 34 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/permission.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d4c897e0e700d96e29ff8a39e91c85f2a8a30909.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/permission.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 69 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d4c897e0e700d96e29ff8a39e91c85f2a8a30909 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b2f924207c19363271885b56fe10dba87ad9fa6f — the merge of head 7a6b091b2a6f589481398f5772d107f02b9071fe into base d4c897e0e700d96e29ff8a39e91c85f2a8a30909, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d4c897e0e700d96e29ff8a39e91c85f2a8a30909 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f3fe6d6cfdb3352163c2ea2275f6057229e4c013

Scope read: card #18783 and all 7 comments (ruling 5725678115, retriage, re-derivation, unlock, claim, the os-dev-report read as claims, amendment 5825601266), card #19578 and PR #19622, and PR #20079's body, files and 41 check-runs, plus AGENTS.md and the code at head. The diff is 12 files, +1702/−352. No packages/spec and no governed surface is touched.

Every base-vs-head reading uses the merge base b76aad5f6. Measured in two detached worktrees (head and merge base, dist closures built under the verify lock, both removed afterwards).

① Derived judgments

  • The member against its docblock: HOLDS on every clause.
    • It returns the whole map, with no object parameter: 36 / 40 / 31 / 37 entries for the four shipped subjects, including '*', the super-user seed and the apiOperations annotations.
    • It is byte-equal to the objects slot: the route and the member call one pure function. The pins (route 39/39, member 10/10) and the direct entry counts agree.
    • It throws on a resolution failure and never answers {}: the resolver's own error surfaces at the engine, with 0 rows written. {} appears only for a subject that resolves no set, which the docblock calls a real answer.
    • It is resolved at most once per write, never per row, and never cached. The counts: 1 for a 40-row bulk update, 1 for a 12-row insert, 1 for validate() over 2 rows, 0 for a system write, 0 for a no-can write even with a throwing resolver, and 2 across two writes, with a revocation seen on the second.
    • When the member is absent, the engine passes NO map: identical to base (admitted, one warn with the carries no permission data error). toEvalPermissions({}) refuses, so absent and empty stay distinguishable.
    • Real maps parse through toEvalPermissions, so a super-user's write does not fail closed on shape.
  • Route byte-equality: TRUE. Whole response bodies are sha256-equal, base against head, for a super-user, a walled org admin, a member, a wall-less org admin, and no sets.
  • Option visibility, base against head:
    • At base the gate fails OPEN: a can()-gated insert is admitted with a warn, and bulk update and validate() behave the same.
    • At head:
      • with no resolver, identical to base;
      • with a withholding map, refused VALIDATION_FAILED / invalid_option with 0 rows;
      • with a granting map, admitted with 0 warns;
      • with a throwing resolver, refused with its own error while the uncovered control write is still admitted;
      • with an off-shape map, refused with 0 rows;
      • a revocation between writes refuses the second write.
    • Nothing refused at base is admitted at head, and no fail-closed path opens.
  • The plain-wildcard gap: reproduced, and pre-existing. With organization_admin_no_bypass + member_default, can() answers false where checkObjectPermission answers true. The base route body already lacks the entry. The head makes the write path consistent with that map. 0 in-repo authored visibleWhen predicates call can(, against a control of 35 visibleWhen lines. Filed as security: the effective object-permission map omits objects covered only by a plain '*' grant, so current_user.can() answers false where enforcement answers true (wall-less org admins) #20083.
  • The census is complete on the reviewer's own grep.
  • Public surface: RIGHT.
    • @objectstack/core gains buildEffectiveObjectPermissions, four folds and two types.
    • @objectstack/objectql gains registerEffectiveObjectPermissionsResolver and EvaluateRulesOptions.permissions?.
    • plugin-security's registered service gains the optional member.
    • plugin-hono-server's export list is 34 = 34, with the six names re-exported from core (ESM identity holds). Nothing left a published export.
  • CI at this head: 41 runs, all success or skipped, and all seven required contexts are green.

② Semver level

core minor, objectql minor, plugin-security minor, plugin-hono-server patch: consistent. Clause-②: yes (widening) is correct: the surface grows, nothing narrows, and packages/spec is untouched.

③ Boundary flags

Implemented-by: claude/issue-18783-server-can-permissions
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

Isolated reviewer: a contract-review-tier subagent, fed only the cards, the ruling, the PR and AGENTS.md; adopted by the seat.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 02:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36088336600 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:      ✓ inverts the card's measured control: the three bogus keys now FAIL parse instead of adding zero warnings  330ms
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > is green against a verbatim copy of the shipped ledgers
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > stays green when the missing path is attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > never bounds a citation attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > prints the citation count and how many are in range, in the documented two
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > stays GREEN on the drifted BEFORE-state — the honest residual this grammar e
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > prints the anchor count and how many resolve, equal on a green run
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > stays GREEN when a `dead` entry carries the SAME rotted pointe
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > declares every status either scanned or explicitly unscanned, 
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on the shipped ledgers and publishes the population be
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the README state table (#7257) > is green against a verbatim copy, and says how many rows it checked
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the generated count artifact (#7377) > is green against a verbatim copy, and says the artifact is curr
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • scripts/liveness/check-liveness.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 0 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7a6b091b2a6f589481398f5772d107f02b9071fe

Scope: the delta over PASS 5825901229 (at f3fe6d6c), after the merge queue removed the PR on the spec liveness gate (queue run 36088336600).

① Derived judgments

  • The delta is one commit and one file: RIGHT. f3fe6d6c..7a6b091b is 1 commit, M packages/spec/liveness/permission.json, +3/−3: the verifiedAt, evidence and note of objects.allowExport. status: live is unchanged, and nothing else changed.
  • Every new or changed sentence is TRUE at this head:
    • packages/core/src/security/effective-object-permissions.ts#annotateEffectiveApiOperations exists. The quoted const exportBit = acc.allowExport ?? wildExport is byte-exact, and the per-object bit is read before the '*' fallback.
    • buildEffectiveObjectPermissions composes it, and is called by both the /auth/me/permissions handler and SecurityPlugin.getEffectiveObjectPermissions.
    • plugin-hono-server re-exports the names.
    • allowExport has 0 word-bounded mentions in current-user-endpoints.ts and 7 in the core file.
    • enforceExportPermission answers 403 EXPORT_NOT_PERMITTED off security.canExport, fail-closed on a throw, and canExport asks checkObjectPermission('export', …).
  • The liveness gate is green at this head, and red at the old ledger:
    • pnpm --filter @objectstack/spec check:liveness exits 0 (756/756 symbol anchors; 612 pairs, 611 anchored, 1 exempt), and check-liveness.test.ts gives 64/64.
    • The control, the same tree with permission.json at the f3fe6d6c blob, exits 1 with 1 UNANCHORED (permission/objects.allowExport → …/current-user-endpoints.ts). That is the queue's finding.
  • Sweep for other pointers the PR's moves left false: none.
    • current-user-endpoints.ts# plus a moved symbol: 0.
    • The ledgers' remaining current-user-endpoints.ts citations (systemPermissions, tabPermissions) still resolve and name their keys.
    • scripts/adr-anchors: 0.
  • CI at this head: 35 runs, 32 success, 3 skipped, 0 failures. Spec property liveness is success, all seven required contexts are success, and mergeable_state is clean.

The judgments of PASS 5825901229 stand for everything outside this delta.

② Semver level

The delta moves no schema, export, authorable key or ledger status. Clause-②: yes (widening) and the changeset's grades stand.

Nit: liveness/ ships in @objectstack/spec's files, and the changeset carries no @objectstack/spec line. Spec is in the same fixed group, so it is versioned and published with the others anyway; only its CHANGELOG line is missing. On main, 24 of 30 ledger-only edits carried one. Non-blocking.

③ Boundary flags

  • The prior record's "No packages/spec … is touched" is stale at this head, because the ledger (not the Zod contract) is now touched. The PR body's patch-round-1 section states this. No governed surface is touched, and Governed Surface Queue Guard is green.
  • The commit's "re-closed the entry's three legs" matches the three legs re-read above.

Implemented-by: claude/issue-18783-server-can-permissions
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

Isolated reviewer: a contract-review-tier subagent, scoped to the delta over record 5825901229; adopted by the seat.

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 0318faf Sep 25, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-18783-server-can-permissions branch September 25, 2026 04:25
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… — 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

1 participant