Skip to content

fix(core): the effective object-permission map covers a plain * grant, so current_user.can() agrees with checkObjectPermission for a wall-less org admin (#20083) - #20132

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20083-effective-map-wildcard
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20083-effective-map-wildcard

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #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-②

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 7b27bd00c7: 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 [finding] nothing on the server side populates EvalContext.permissions, so can is bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 precedent for an added optional member of a published parameter type.
  • 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 7b27bd00c7 (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 feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079 and is still unreleased. Its "Known gap, not changed here" paragraph describes exactly the plain-wildcard gap this PR closes, and would have shipped false in the same release. The paragraph is rewritten so that every sentence is true at this head. It names this PR's changeset and states the remaining super-user divergence in one neutral sentence, without claiming a fix. check-empty-changeset / Check Changeset goes red on the foreign-changeset rule by design. 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 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, /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 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 (.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 feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079's move of the merge into core, and it holds for that move. Read against the previous release, though, the response of a plain-wildcard subject now changes in this same release, through this PR.

  • 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.

…mission map

buildEffectiveObjectPermissions merged each set's explicit entries and kept
'*' as a key of its own, so an object covered only by a plain wildcard (no
super-user bit) had no entry, and current_user.can() read it as no grant
where PermissionEvaluator.checkObjectPermission allows. A new pass puts each
set's plain '*' grants on the registered public objects that set does not
name, after the super-user seed and before the fold.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
core: the coverage pass's rules (registered public objects only, a set's
explicit entry excludes its own wildcard, another set's wildcard widens a
present entry, true bits only, no no-verb entries, super-user wildcards left
to the seed and fold, seed-before-cover ordering).

plugin-security: a table-driven parity pin — shipped and authored plain
wildcard subjects x registered objects x every can() verb, the real can()
over the member's map against PermissionEvaluator.checkObjectPermission;
the member and route byte-equality pins now exercise the new pass.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
The changeset tells /auth/me/permissions and getEffectiveObjectPermissions
readers what the objects slot gains. The access-security parity item gains
the wall-less org admin persona, its step and acceptance clause, the
producer's source anchor and the unit parity table in automated.ref.

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

The coverage step reads access.default off each allSchemas entry. Typing the
member unknown keeps every call that compiled before compiling; the element
type still gains an optional member, a type widening, so the changeset is
graded minor.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
Measured on a booted showcase: showcase_semantic_zoo is named by no set and
is absent from the wall-less org admin's map before this change, while
showcase_project is named read-only by showcase_member_default and widened by
the organization_admin_no_bypass wildcard. The step and clause now say so.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 1 package(s): @objectstack/core, touching 15 documentable anchor(s).

16 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 bc80e16260597d52b062b7e1ee810c7b9a99aed2.

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

What this run could not see
  • 1 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 — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2

⚠️ 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 bc80e16260597d52b062b7e1ee810c7b9a99aed2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…p is closed in this release

Deliberate correction of the pending 18783-server-can-option-visibility
changeset: its "Known gap, not changed here" paragraph described the
plain-wildcard coverage this branch adds, and read false once it lands in the
same release. Only that paragraph is rewritten; it names this branch's
changeset and states the remaining super-user divergence without claiming a
fix. Every other sentence of the file is byte-identical.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 228724b3f35bddf84f42190db316624e264cdad4

Scope: 7 files (+508/−29) on merge base 7b27bd00c7:

  • packages/core/src/security/effective-object-permissions.ts (the producer);
  • its tests, and the route and member parity pins (test-only in plugin-hono-server and plugin-security);
  • the checklist item docs/qa/platform-checklist/areas/access-security.json;
  • .changeset/20083-effective-map-plain-wildcard.md;
  • the DELIBERATE CORRECTION of .changeset/18783-server-can-option-visibility.md.

No governed surface and no packages/spec/src is touched.

Measured in detached worktrees at head and base, each with its closure built.

① Derived judgments

  • Parity, map against enforcement, 63 registered objects × 10 verbs. The map answer is formula current_user.can() over toEvalPermissions(map); enforcement is PermissionEvaluator.checkObjectPermission. In every row at both trees, the producer, the real /auth/me/permissions handler and the real SecurityPlugin.getEffectiveObjectPermissions were byte-equal.
    • organization_admin_no_bypass + member_default: 37 → 64 entries, under-granted 106 → 0, over-granted 0 → 0. crm_account goes from absent to create / read / edit / delete. The private crm_secret stays absent.
    • viewer_readonly + member_default: under 32 → 0, over 0.
    • An explicit crm_account read plus another set's plain '*': under → 0, over 0. The present entry widens bit by bit.
    • One set holding '*' and a narrower explicit entry: under → 0, over 0. The same set's explicit entry wins on both sides.
    • An export-only '*': under 1 → 0, and no entry is added for other objects.
    • The walled organization_admin: the 38 over-granted cells are the SAME cells at base and head, with the map sha byte-identical (security: the effective permission map folds a super-user '*' over the same set's narrower explicit entries — a walled org admin gets can('sys_position','edit') true where enforcement refuses #20136, pre-existing).
    • admin_full_access, member_default and all-false '*': byte-identical at base and head.
    • An unregistered object gets no entry at either tree.
    • The head adds ZERO over-granted cells in any subject.
  • The write path, measured on a real engine with the real SecurityPlugin resolver: an option gated on current_user.can('crm_account', 'edit') was refused VALIDATION_FAILED / invalid_option for the wall-less org admin at base, and is admitted at head. The controls stay refused at both trees (member_default, the one-set-narrower subject and viewer_readonly). The security: the effective permission map folds a super-user '*' over the same set's narrower explicit entries — a walled org admin gets can('sys_position','edit') true where enforcement refuses #20136 cell is admitted at both, unchanged.
  • The pins discriminate. With the coverage call ablated: plugin-security 7 failed / 12 passed, core 6 failed / 9 passed, hono 1 failed / 2 passed, the dev's counts exactly. Each was restored by blob, and the dist preflight was checked.
  • The public type widens, measured. The allSchemas element gains access?: unknown. It is reachable from the entry type graph and appears in the built dist/index.d.ts. tsc refuses access at base and accepts it at head. No new export name.
  • The checklist step is TRUE through the real producer over the showcase's declared objects and shipped sets:
    • showcase_semantic_zoo goes from absent to create / read / edit / delete;
    • showcase_project allowEdit goes from false to true;
    • the private sys_secret stays absent, and enforcement denies it with 403.
      check:platform-checklist exits 0. The live 2xx / 403 traces are UNMEASURED here.
  • Consumers: there is no non-test requester of /auth/me/permissions in the repo. objectui's can() is UNMEASURED.
  • CI at this head: 41 runs, 34 success, 5 skipped, 2 failure, 0 pending. All required contexts are success. The two failures are Check Changeset, whose logs name only .changeset/18783-server-can-option-visibility.md (the foreign-changeset rule).

② Semver level

③ Boundary flags

Implemented-by: claude/issue-20083-effective-map-wildcard
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 08:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit fe677ae Sep 25, 2026
41 of 43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20083-effective-map-wildcard branch September 25, 2026 08:37
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ry every bit the server grants, so can(object, 'transfer') agrees with checkObjectPermission (objectstack-ai#20134) (objectstack-ai#20145)

Fixes objectstack-ai#20134

Clause-②: yes (widening)

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

- **`transfer`.** The fold put only read, create, edit and delete on an
entry, so `can(X, 'transfer')` was `false` for `admin_full_access` on
every object, and for the walled `organization_admin` on every object
its own set does not name. The server grants `transfer` through
`modifyAllRecords`.
- **A super-read wildcard's own plain bits.** `'*': { viewAllRecords,
allowEdit }` put only the read on an entry.
- **A super-user wildcard carrying `allowExport`.** It skipped the
super-user seed for every unrestricted object. This is the third
location of the family recorded on objectstack-ai#20136, measured at 214 cells by the
reviewer of objectstack-ai#20132.

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

## What changes

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

- **`seedSuperUserRestrictedObjects`** places an entry for every
registered object the merged map lacks, whenever the merged `'*'`
carries a super-user bit. It used to skip an unrestricted object whose
export stays allowed, because that object needs no `apiOperations`. The
seed no longer resolves the operation set at all. Its signature and its
`{allow*: false}` initial shape are unchanged.
- **`foldSuperUserWildcardGrants`** is new and module-private. It runs
right after `foldWildcardSuperUser` and before the managed-write clamp.
For each set whose `'*'` is a super-user wildcard, it walks every entry
that set does not name. On each such entry it sets every grant bit the
spec's `objectPermissionGrants` says that wildcard grants: the one fold
`checkObjectPermission` itself asks. This is `resolveObjectPermission`'s
per-set reading:
- a set that names an object keeps its explicit entry as its whole
answer for that object;
- a private object is covered, as a super-user wildcard covers it on the
server;
  - only `true` bits are set.

For a super-user wildcard the read cell is always true, so its export
cell is exactly its own `allowExport`, the grant half the evaluator's
export conjunction reads. The bits are derived, not listed.
- **`foldWildcardSuperUser`** has an unchanged body. Its docblock now
says what the merged reading cannot carry, and names its two known
over-grants (below). The old line "exactly as broad as real enforcement
— never broader" was false for those cells.

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

## Measurement

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

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

| subject (resolved sets) | entries | under | over | map bytes |
|---|---|---|---|---|
| `admin_full_access` | 64 → 64 | 63 → **0** | 0 → 0 | changed |
| `admin_full_access` + `member_default` | 66 → 66 | 63 → **0** | 0 → 0
| changed |
| `admin_full_access` + `organization_admin_no_bypass` +
`member_default` | 66 → 66 | 63 → **0** | 0 → 0 | changed |
| walled `organization_admin` + `member_default` | 66 → 66 | 30 → **0**
| 38 → 38 (same cells) | changed |
| `'*': { modifyAllRecords }` | 64 → 64 | 63 → **0** | 36 → 36 (same
cells) | changed |
| `'*': { modifyAllRecords, allowCreate, allowRead }` | 64 → 64 | 63 →
**0** | 0 → 0 | changed |
| `'*': { viewAllRecords, allowEdit }` | 64 → 64 | 54 → **0** | 0 → 0 |
changed |
| `'*': { viewAllRecords, allowEdit, allowTransfer, allowDelete }` | 64
→ 64 | 153 → **0** | 0 → 0 | changed |
| `'*': { viewAllRecords }` | 64 → 64 | 0 → 0 | 0 → 0 | identical |
| `'*'`: admin bits + `allowExport` | 53 → 64 | **214 → 0** | 0 → 0 |
changed |
| `'*': { viewAllRecords, allowExport }` | 53 → 64 | 74 → **0** | 0 → 0
| changed |
| `'*'`: admin bits + `allowExport`, + `member_default` | 56 → 66 | 206
→ **0** | 0 → 0 | changed |
| explicit `crm_account: read` beside another set's super-user `'*'` |
54 → 64 | 136 → **0** | 0 → 0 | changed |
| ONE set: super-user `'*'` and a narrower explicit `crm_account` | 64 →
64 | 62 → **0** | 7 → 7 (same cells) | changed |
| super-read `'*'` beside a plain `'*'` | 64 → 64 | 0 → 0 | 0 → 0 |
identical |
| `admin_full_access` beside a plain export-only `'*'` | 53 → 64 | 161 →
**0** | 0 → 0 | changed |
| `member_default` | 31 → 31 | 0 → 0 | 0 → 0 | identical |
| `organization_admin_no_bypass` + `member_default` | 64 → 64 | 0 → 0 |
0 → 0 | identical |
| `viewer_readonly` + `member_default` | 64 → 64 | 0 → 0 | 0 → 0 |
identical |
| the four objectstack-ai#20083 plain-wildcard shapes | — | 0 → 0 | 0 → 0 | identical
|

- **No narrowing, measured entry by entry, base against head over all 23
subjects.** No entry is removed, no `true` bit turns `false`, and no
`apiOperations` list loses or gains an operation. The head adds 53
entries (the `allowExport` shapes' unrestricted objects) and `true`
bits: `allowTransfer` × 688, `allowExport` × 212, `allowEdit` × 42,
`allowDelete` × 18.
- **The head adds ZERO over-granted cells in any subject.** The three
pre-existing over-grant sets are the same cells at base and head.

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

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

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

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

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

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

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

## The objectstack-ai#18990 ruling and the seed's skip

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

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

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

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

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

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

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

- **The merged bypass folded into an entry the super-user set itself
names narrower.** On the 63-object fixture this is the walled
`organization_admin`'s 38 cells (create, edit, delete and import on its
five read-only RBAC objects, and edit on `sys_organization`). A one-set
authored shape shows 7.
- **`allowCreate` pulled on `modifyAllRecords` alone.** The spec's
`objectPermissionGrants` gives this no create cell. A bare `'*': {
modifyAllRecords: true }` shows 36 create/import cells. No shipped set
has this shape, since both shipped super-user wildcards carry
`allowCreate: true`. This second location is not in objectstack-ai#20136's body. The
seat has routed it to objectstack-ai#20136's enumeration (amendment 5831894689).

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

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

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

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

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

## Clause-②

- **The behavioural accept set widens.** `can()` answers widen to match
enforcement, and a `can()`-gated write for these subjects is now
admitted. Nothing narrows (see the entry-level diff above), so
`@objectstack/core` is `minor`, following the objectstack-ai#20083 and objectstack-ai#18783
precedents.
- **Public type:** no exported name or type changes.
- `check-changeset-no-major --base origin/main` and
`check-adr-0087-registration --base origin/main` both exit 0.

## DELIBERATE CORRECTION: three pending changesets

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

- **Q1 → A** (seat amendment 5831894689 on objectstack-ai#20134) covers the objectstack-ai#18783
clause.
- The contract review FAIL 5832348775 (prose only) names the objectstack-ai#18931 and
objectstack-ai#18990 bullets, and seat amendment 2 (5832355884) rules them corrected
in this PR.

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

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

- **Before:** 「The map still differs from
`PermissionEvaluator.checkObjectPermission` for subjects holding a
super-user wildcard: an entry the super-user set itself names narrower
can read as granted, and an entry reached through a super-user wildcard
carries no `transfer`.」
- **After:** 「The map still differs from
`PermissionEvaluator.checkObjectPermission` for subjects holding a
super-user wildcard: an entry the super-user set itself names narrower
can read as granted. Its missing `transfer` is closed in this same
release (`.changeset/20134-super-user-entries-every-bit.md`).」

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

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

- **「an entry the super-user set itself names narrower can read as
granted」** holds. These are objectstack-ai#20136's cells: 38 for the walled
`organization_admin` on the 63-object fixture, and 7 for the one-set
authored shape. Both are byte-identical at base and head and
pre-registered in `KNOWN_OVER_GRANT`.
- **「Its missing `transfer` is closed in this same release」** holds.
`transfer` under-grants are 0 in every super-user row of the fixture
table and 78 → 0 on the booted showcase.
- **The `create`-on-`modifyAllRecords`-alone cells are not named there,
and do not need to be.** The sentence does not claim to list every
divergence. That over-grant is disclosed in this release's own
`.changeset/20134-super-user-entries-every-bit.md` (「Still broader than
the server, unchanged here」), and no shipped set has the shape. Naming
it in the objectstack-ai#18783 entry would widen the correction beyond the one clause
Q1 authorizes.

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

- **Before:** 「**The seed now applies annotate's own predicate**:
resolve with the export slot annotate will read for the entry being
seeded, and skip only an object that is unrestricted **and** keeps
`export`. A seeded entry carries no `allowExport` of its own and
`foldWildcardSuperUser` does not add one, so annotate's `acc.allowExport
?? wildExport` resolves to the same wildcard bit the seed read — the two
passes cannot diverge again.」
- **After:** 「**The seed and annotate cannot diverge again.** Since
objectstack-ai#20134 the seed places an entry for every registered object a super-user
wildcard reaches that the merge left without one, and
`annotateEffectiveApiOperations` alone decides which entries carry
`apiOperations`: an object that is unrestricted **and** keeps `export`
gets none.」

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

**Why the new bullet is TRUE.**

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

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

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

- **Before:** 「**`apiOperations` is attached through the predicate
already shared with the modify-all class** — an unrestricted object
whose export stays allowed is still skipped, because for it the client's
default-allow path is already right.」
- **After:** 「**`apiOperations` is attached through the predicate
already shared with the modify-all class** — an unrestricted object
whose export stays allowed still gets no `apiOperations` (the entry
itself is seeded since objectstack-ai#20134), because for it the client's
default-allow path is already right.」

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

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

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

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

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

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

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

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

## Tests

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

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

**New and changed pins:**

- plugin-security `get-effective-object-permissions.test.ts`: the objectstack-ai#20083
parity table gains 9 super-user rows, plus 2 spelled-out cases and a
registration-integrity case (31 tests).
- core `effective-object-permissions.test.ts`: a 5-case `[objectstack-ai#20134]`
battery, and one updated key-order assertion (20 tests).
- plugin-hono-server: 3 seed pins inverted, and 2 route assertions
added.

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

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

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

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

## Acceptance notes

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ing on apiEnabled: false, export only where the export door admits it (objectstack-ai#20135) (objectstack-ai#20151)

Fixes objectstack-ai#20135

Clause-②: no

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

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

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

## What changes

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

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

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

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

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

## Measurement

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

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

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

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

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

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

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

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

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

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

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

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

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

## Clause-②

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

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

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

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

## Ablation

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

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

## Gates, at head `f2904cd05b`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Acceptance notes

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants