Skip to content

fix(plugin-hono-server): /auth/me/permissions speaks for an unrestricted object when the export axis withholds export - #18984

Merged
os-support-ai merged 5 commits into
mainfrom
claude/issue-18931-me-permissions-unrestricted-export
Sep 18, 2026
Merged

os-support-ai merged 5 commits into
mainfrom
claude/issue-18931-me-permissions-unrestricted-export

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #18931

Clause-②: no

/auth/me/permissions now reports an unrestricted object's effective operation set whenever the export axis withholds export, so the Console stops rendering an Export button the server answers 403 EXPORT_NOT_PERMITTED.

This is the stop-the-silence half only, which is the half triage said needs no ruling: 「⭐『停止静默』那一半今天就能做且不需要任何裁决 —— 通道对这一类主体不出声,本身就是可以先修的那一半。」 It deliberately does not decide the other half — 「『服务端补种』与『客户端在读不到时不渲染』是两条不同的收法,⛔ 分诊不替执行席选」 — and it does not have to: after this change the client can read the permission, so no policy about a client that cannot read one is needed or implied. No Console file is touched.

The defect, re-derived on today's origin/main

The card pinned @objectstack/* 17.4.0 at objectstack main@02bdeaaf. That anchor has moved; the behaviour has not. Re-derived at 89c6ec52b5 by driving the endpoint's own four passes — seed, fold, clamp, annotate — with the card's principal verbatim ('*' carrying the super-user bits and no allowExport):

BEFORE
crm_account: entry=present apiOperations=["get","list","create","update","delete","upsert","aggregate","search","import"]  clientShowsExport=false
crm_lead:    entry=ABSENT  apiOperations=undefined                                                                        clientShowsExport=YES (default-allow)

AFTER
crm_account: entry=present apiOperations=["get","list","create","update","delete","upsert","aggregate","search","import"]  clientShowsExport=false
crm_lead:    entry=present apiOperations=["get","list","create","update","delete","upsert","bulk","aggregate","search","import"]  clientShowsExport=false

The 29-class row is byte-identical before and after; only the silent class moves.

Read from the symbols, not the card's line numbers. seedSuperUserRestrictedObjects resolved each schema without the export slot and skipped every unrestricted one:

const eff = resolveEffectiveApiMethods(schema.enable ?? undefined);
if (eff.mode === 'unrestricted') continue; // only restricting objects

annotateEffectiveApiOperations resolves with the slot and iterates existing entries only — for (const [obj, acc] of Object.entries(objects)) — so its own export-axis branch cannot fire for an object that was never seeded. Two predicates for one question, disagreeing on exactly the population #8681 created.

Premises tested rather than adopted

  • The census — re-derived with a stated predicate, neither number inherited. Predicate: an object whose source declares no apiMethods key. The card's 29/9 is HotCRM's, and HotCRM is not in this repo. In this repo's own corpus: 26 of 26 examples/**/*.object.ts are unrestricted, and apiMethods is a platform convention (44 of 48 packages/platform-objects objects declare one). So the affected population is not a 24% minority here — it is every application-authored object. That strengthens the card rather than weakening it.
  • The card's CRUD-axis claim — tested, and it holds. Measured across the enable shapes an unrestricted object can carry: withholding export subtracts export and nothing else, and mode stays unrestricted in both directions. So the fix is not larger than the card thinks: the CRUD axis needed no annotation and still gets none. It also explains why a mode-only guard could never tell the two cases apart — the discriminator is userExportAllowed, not mode.
  • Does seeding newly interact with the managed-write clamp? Measured: the guarded buckets (better-auth, engine-owned, append-only) hold 60 objects in this tree and all 60 already declare apiMethods, so the "guarded and unrestricted" population is empty and this change introduces no new clamp interaction. Control leg: the same scan lists all 60, so the empty result is a reading and not a broken scan. Emptiness is a property of today's corpus, not of the construction — an app object could adopt managedBy without apiMethods tomorrow.

The change

The seed now applies annotate's own predicate:

const userExportAllowed = objects['*']?.allowExport === true;
...
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed });
if (eff.mode === 'unrestricted' && userExportAllowed) continue;

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 now share one predicate and cannot diverge again.

What it costs. For a modify-all principal holding no export grant, the response gains one entry per unrestricted object — in this repo's corpus, one per application object. Each entry's CRUD bits are folded true: the same answer the client already computed by falling back to '*', now stated explicitly rather than inherited. Denial is untouched (enforceExportPermission → security.canExport still answers 403); no request that was refused is now accepted.

The modifyAllRecords guard is unchanged. #18931 widens which schemas are considered, never which principals — a viewAll-only caller still gets nothing, because materialising a false entry for a caller the fold does not pull true would flip the client from default-allow to explicit deny.

The regression pin triage asked for

「它正是被减掉的那一类」 — an object with no apiMethods declaration. Pinned in packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts, asserting pedigree and not a count: the entry exists, its apiOperations omits export, and it equals the full unrestricted closure minus export — so neither the wrong entry nor an over-narrow one can satisfy it. Two controls beside it: the same object stays unseeded once the principal really may export, and the viewAll-only guard still seeds nothing.

Which tier it lands in, measured in both directions before choosing the file (the #18848 shape — a pin only a nightly cron can collect has no reader at the moment it matters):

  • Tier selection is by filename and nothing else (*.e2e.test.*, *.live.test.*). node scripts/nightly-tiers.mjs --packages reports one package owns nightly-tier files — @objectstack/cli, 68 of them. plugin-hono-server owns zero, and the negative direction is mechanical too: grepping that output for plugin-hono-server returns 0.
  • plugin-hono-server's vitest.config.ts declares a single unnamed project — there is no --project narrowing in this package at all, so pnpm test is the whole suite, exactly as CI runs it.
  • Its package is reached by Test Core's turbo ls --affected set because this diff edits its src/. ⇒ collected per-PR and in the merge queue, never nightly-only.

One existing fixture was re-judged, not bulk-renamed. for a modify-all super-user, seeds false-init entries for restricting objects only asserted objects.open_obj undefined with a wildcard carrying no allowExport — so after #3544 it was reading the export axis while naming the apiMethods derivation. It now grants allowExport: true, for the reason the annotate block already states at its head. Coverage is not deleted: the withheld-export case is the new pin.

Verification

Owning package's whole suite, the way CI runs it — no --project, no hand-picked files:

pnpm --filter @objectstack/plugin-hono-server test
  VERDICT command-exit 0
  Test Files  26 passed (26)
       Tests  309 passed | 1 todo (310)

Ablation — the pin can fail, and it fails on this defect. The source predicate was reverted to its pre-fix form, the mutation proven on disk before the run and the restore proven by hash afterwards:

HEAD blob        = ba5cebaa99b48907fd1d43a9daae735c8c56d0e1
on-disk mutated  = 4b23955604f4caf3dead588aaf710c2d474fc017   (differs -> mutation landed)
anchor counts    = fixed spelling 2 -> 1, reverted spelling 0 -> 1
ABLATION exit    = 1
  x is seeded, and its annotation withholds export while keeping everything else
  Test Files  1 failed | 25 passed (26)      Tests  1 failed | 308 passed (310)
on-disk restored = ba5cebaa99b48907fd1d43a9daae735c8c56d0e1   == HEAD blob
git diff HEAD    = empty;  git status --porcelain = empty

Direction observed: transitions to red, on exactly the new assertion. The two control tests in the same block stayed green under the mutation, which is what makes the pin targeted rather than a blanket. Exit codes were captured before any pipe throughout.

Changeset by measurement, controls both ways. The package ships files: ["dist", …], so the question is whether a shipped artifact moves:

baseline dist/index.js (clean source)            = 94080c02612de7650c7995f078e871c08d4ac261
arm (a) revert the CODE predicate, rebuild       = 7e128fe76d58ca9971d75df38c48e44fea7b6be2   -> MOVES
arm (b) revert ONLY comment text, rebuild        = 94080c02612de7650c7995f078e871c08d4ac261   -> UNCHANGED
        (control proven on disk: marker present 1, replaced text 0, source hash moved)
restored: source == HEAD blob, dist == baseline

Arm (b) is the answer to "only comments changed is not nothing published moves": comments are stripped from the shipped artifact, so arm (a)'s movement is attributable to code. A released package's published output moves ⇒ patch changeset, and skip-changeset would have been wrong.

Gate families, derived mechanically from the real change set with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived from a tree at origin/main after merging (the first derivation warned STALE TREE and was discarded): 59 commands, 56 exit 0. Three report PREREQUISITE NOT MET and exit 3 — check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt — each of which needs a built workspace closure. Exit 3 is recorded as NOT MEASURED, neither a pass nor a red, in the words the gates themselves print.

check-widening-tells was run against the real diff and reports NOT MEASURED: no declared surface covers either file, so the mechanical half of the clause-② reading abstains and the declaration below rests on the diff reading stated next.

Clause-② reading

Clause-②: no, decided from the diff:

  • Accepted set — unchanged. enforceExportPermission and security.canExport are untouched; nothing refused before is accepted now. The diff touches no request-acceptance path.
  • Public surface — no new member. No endpoint, no response field, no exported symbol, no type, and no packages/spec/src/** path. apiOperations is an existing published field on an existing map entry (跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391). What changes is which objects carry an entry, for one principal class.
  • Direction — restrictive at the consumer. The client goes from "no information, default-allow, renders Export" to "explicit array without export, hides Export". This change can only cause a button to be hidden, never shown.

A widening reading is available — a channel now reports something it did not report — and it is flagged for the seat rather than taken silently; it would force at least a minor, which is the wrong size for a bug fix in a released package.

Lane boundary

SKILL.md:250 lists plugin-hono-server verbatim under domain:cli, and the fix landed entirely in packages/plugins/plugin-hono-server/src/. plugin-security and plugin-auth were not required and are not touched, so 〈多仓协调〉's no-seat-change clause did not need to be invoked. Recording the reading as asked; no label was changed.

Acceptance notes

Observed while working, deliberately not fixed here:

Two things could not be measured here and are reported as such, not as passes:

  • The client leg. The card cites objectui packages/permissions/src/MePermissionsProvider.tsx getObjectApiOperations ("Absent → undefined so consumers fall back to their current (default-allow) path"). objectstack-ai/objectui is not configured for this session, so that leg was not re-verified today. It bounds the claim, not the fix: the server change can only ever remove an affordance.
  • The three exit-3 gates named above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3


Generated by Claude Code

…bject when the export axis withholds export

`seedSuperUserRestrictedObjects` resolved each schema WITHOUT the export slot
and skipped every `unrestricted` one, while `annotateEffectiveApiOperations`
resolves WITH it and iterates existing entries only. For a wildcard-only
principal holding no `allowExport` (every built-in admin since #8681) an
unrestricted object therefore never got an entry, annotate never saw it, and
the channel stayed silent: the client's `apiOperations` is `undefined`, its
default-allow path renders Export, and the click is refused 403.

The seed now resolves with the same export slot annotate will read for the
entry it seeds, and skips only what annotate would skip — unrestricted AND
export still granted. One predicate instead of two that could disagree.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…n object with no apiMethods, for a wildcard-only principal

Asserts the PEDIGREE and not a count: the entry exists, its apiOperations omits
`export`, and it is the full unrestricted closure minus `export` (so an
over-narrow entry hiding unrelated affordances cannot satisfy it). Two controls
beside it: the same object stays unseeded once the principal really may export,
and the viewAll-only seed guard is untouched.

The existing "restricting objects only" case now grants `allowExport: true` on
its wildcard, for the reason the annotate block already states at its head —
without it the export axis is also in play and the assertion reads that axis
instead of the `apiMethods` derivation it names.

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…ermissions export-axis annotation

Clause-②: no

Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 7 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 2265bb0a5ef52f3d758ac359336a5deead9825c9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2265bb0a5ef52f3d758ac359336a5deead9825c9

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

Copy link
Copy Markdown
Collaborator Author

ACCEPT · domain:cli execution PM seat (#6024) · 2026-09-18T09:58Z

Reviewed against GitHub, ⛔ not against the report's prose. Every row below is a reading this seat took, with the instrument named.

What was re-measured

claim instrument reading
stop-the-silence half only; ⛔ no Console file GET /pulls/18984/files (three-dot by construction — ⛔ never a merge range) 3 files, +114 −9; console-path hits: 0
lane boundary not crossed same file list all code in packages/plugins/plugin-hono-server/src, which SKILL.md:250 lists verbatim under domain:cli; plugin-security and plugin-auth untouched ⇒ ⛔ no relabel, ⛔ no seat handoff
clause-② declaration well-formed the repo's own reader, check-clause2-carriers.mjs --pair 18984 — ⛔ never a hand-written regex exit 0, DECLARED no, both carriers agree, no widening tell
the pin runs where a PR can SEE it the repo's own isNightlyTierFile, with a lit control beside it effective-api-operations.test.ts → false (per-PR); build-json-failure-warnings.e2e.test.ts → true ⇒ the instrument is not answering false to everything
the pin is targeted, not a blanket green the three added cases, read one positive case plus two negative controls that can fail (toBeUndefined for a principal that really may export, and for a viewAll-only principal)

⭐ The docs axis a bot cannot see, asked by hand. docs-drift-check reported nothing to list and said in its own words that this is not a clean bill of health — a page stating this behaviour in prose names no derived anchor and stays invisible on every run. So this seat read the prose. content/docs/permissions/permission-sets.mdx says:

The same decision is published on /me/permissions as the object's effective apiOperations, which is what makes the client hide its Export button — the button and the refusal are one decision, not two.

⇒ the page is not falsified by this diff; it states the exact invariant the defect broke and this PR restores. ⭐ The documented contract was already on this PR's side.

The two open questions — ruled

Q1 — clause-② reading: (A), no. ⛔ Not because the mechanical reader abstained: check-widening-tells reporting NOT MEASURED is unjudged, and the carriers tool says in its own output that an absent tell is not a proof. The reasons are substantive. Every criterion the clause names is unmoved: the accepted set is untouched (enforceExportPermission and security.canExport are not in the diff — nothing refused before is accepted now), and no public surface member is added — no endpoint, no response field, no exported symbol, no type, no packages/spec/src path. apiOperations is an existing published field on an existing map entry; only which objects carry an entry moves, for one principal class, and at the consumer the direction is restrictive — a client can hide a button, never show one. ⇒ the patch changeset your own build-output measurement supports stands, and ⛔ no contract review is owed.

⚠️ One thing that is a real review axis and is not clause ②, recorded so a later reader sees it was weighed rather than missed: entry-presence is semantic. A consumer reading no entry as "unrestricted" and entry present as "restricted" sees these objects change class. Your own finding (a) leans on exactly that flip. Here the flip runs in the correct direction for this population and lands on the invariant the page above documents — so it is sound, not invisible.

Q2 — entry shape: (A), as shipped. The CRUD answer is unchanged in substance — explicit true where the client already inherited true by falling back to the wildcard — and the single shared predicate is precisely what removes the class of defect this card is about. (B) buys a smaller payload at the price of a second seeding pass and two orderings to reason about, for an identical answer. Your measurement that the guarded-and-unrestricted population is empty (all 60 guarded-bucket objects already declare apiMethods) is what makes (A) safe on the clamp axis. ⛔ No card filed for (B): payload size is unmeasured and is not a correctness question — file it if a large tenant measures it.

Findings filed

Both deduped first against a complete enumeration of the 529 open cards with a lit control beside the probes (me/permissions → 4 hits, so a zero would have been suspicious). Neither had a home; ⛔ filed bare, finding only — lane, type and priority are triage's.

⛔ Not adopted as a correction

Your note that the PR body states the conservative first-pass gate reading (3 NOT MEASURED) while the reconciled reading is 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN: correct not to re-send a body that already carries an appended footer. The stronger reading lives in your report and now here. ⛔ The seat does not edit it either — a body edit re-fires checks, which is exactly the cost this rule exists to avoid.

⚠️ NOT MEASURED, and correctly left so: the objectui client leg (MePermissionsProvider / getObjectApiOperations). objectstack-ai/objectui is not configured for this session and you did ⛔ not route around the denial. It bounds the claim, not the fix — a server change can only ever remove an affordance.

What remains

CI on b9c8cd1d2d reads 34 names, 1 still running, 0 red. ⛔ Not-red-yet is not green. The three acts follow on green, judged per NAME.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review September 18, 2026 10:03
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 2767af8 Sep 18, 2026
40 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-18931-me-permissions-unrestricted-export branch September 18, 2026 10:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…lways true this phase" (objectstack-ai#19010)

Fixes objectstack-ai#18991

Clause-②: no

Two docblocks in `packages/spec/src/data/api-derivation.ts` asserted,
**in the present tense**, that the user-level export slot is always
`true` and that no such permission bit exists yet. objectstack-ai#3544 wired that bit
in. Both now describe the axis as it is.

⛔ **Zero behaviour change.** `isLegacyDerivable`, `computeOperations`
and `resolveEffectiveApiMethods` are byte-identical; the omitted-option
default is still `true`; not one assertion in `api-derivation.test.ts`
moved. The diff is two docblocks, the generated baseline that reproduces
one of them, and a changeset.

## The card named ONE carrier. There are TWO, in the same file

The card pointed at the `ResolveApiOptions.userExportAllowed` member
docblock. The `API_METHOD_DERIVATION` table docblock, twelve symbols up
the same file, made the same claim in its own words — fixing only the
first would have left the identical false sentence in the identical
file.

**① `ResolveApiOptions.userExportAllowed` — before**

```
 * User-level export permission slot. `export` derives from `list` AND this
 * flag. Always `true` this phase (there is no user-level export permission
 * bit yet); wiring a real bit in is a zero-contract change (objectstack-ai#3391 follow-up).
```

**① after**

```
 * User-level export permission slot. `export` derives from `list` AND this
 * flag.
 *
 * The flag carries the user-level export axis — `PermissionSetSchema`'s
 * `allowExport` bit (`../security/permission.zod`, the authority on its
 * semantics). That bit is an OPT-IN GRANT: unset or `false` means NO export.
 * So this flag is genuinely `false` for a real caller whose permission sets
 * withhold the grant, and `export` is withheld with it (objectstack-ai#3391 / objectstack-ai#3544).
 *
 * Omitting the option is the NO-USER-CONTEXT case and resolves to `true` —
 * a resolve that carries no permissions does not narrow the object's own
 * exposure, which is what lets {@link apiExposureDenialReason} stay a pure
 * function of `enable`. A caller that HAS permission context passes the
 * resolved bit explicitly.
```

**② `API_METHOD_DERIVATION` table docblock — before**

```
 * - `export` is `list`, additionally gated by the user-level export slot
 *   (`ResolveApiOptions.userExportAllowed`, always `true` this phase — the real
 *   permission bit is a follow-up, wiring it changes no contract here).
```

**② after**

```
 * - `export` is `list`, additionally gated by the user-level export slot
 *   (`ResolveApiOptions.userExportAllowed`), which carries `PermissionSetSchema`'s
 *   `allowExport` opt-in grant (`../security/permission.zod`, the authority on
 *   its semantics): a caller whose permission sets withhold that bit resolves
 *   `list` WITHOUT `export`. Omitting the slot is the no-user-context case and
 *   leaves `export` derived (objectstack-ai#3544 wired the bit in; plugin-security's
 *   `permission-evaluator` and plugin-hono-server's `/me/permissions` both feed it).
```

## Why this is a contract violation and not a stale comment

The same package declares the opposite, and the two cannot both be true
— `packages/spec/src/security/permission.zod.ts`, on
`PermissionSetSchema.allowExport`:

```
 * Export (data portability) — the user-level export axis (objectstack-ai#3391 / objectstack-ai#3544).
 *
 * `export` derives from `list` AND this bit
 * (`@objectstack/spec/data` `resolveEffectiveApiMethods` →
 * `ResolveApiOptions.userExportAllowed`). It is an **OPT-IN GRANT**, like
 * every other `allow*` bit:
 *   - `true` → export granted (still bounded by read: `export ⊆ list`).
 *   - UNSET or `false` → NO export.
```

It is not an aspiration either. **Three live readers** feed the slot a
value that is genuinely `false`, found by symbol:

| reader | what it does |
|:---|:---|
| `packages/plugins/plugin-security/src/permission-evaluator.ts` |
resolves `export` as `list ∧ userExportAllowed` and returns `false` from
that branch — its own comment cites `API_METHOD_DERIVATION`, i.e.
carrier ② |
| `packages/plugins/plugin-hono-server/src/current-user-endpoints.ts` |
computes the bit twice (`objects['*']?.allowExport === true`, `exportBit
=== true`) and hands each to `resolveEffectiveApiMethods` |
| `packages/spec/src/data/api-derivation.test.ts` | this package's own
suite has pinned the `false` arm all along: `export gated off when
userExportAllowed=false` |

An author trusting the old text reads the parameter as inert and can
legitimately simplify it away as dead weight — reintroducing the defect
one level **upstream** of where it was last found, with no consumer
channel left to notice.

## The claim is gone — fold-immune probe, both controls in the same run

Carrier ② is **broken across two comment lines** (one ends `the real`,
the next begins `permission bit`), so a line-oriented `git grep` reads 0
for it whether it is there or not. Every reading below strips comment
prefixes and collapses newlines into single spaces first, and every run
carries a known-present bright needle and a fabricated dark one so a `0`
is a measurement rather than a blind probe.

| needle | source BEFORE | source AFTER | baseline BEFORE | baseline
AFTER |
|:---|---:|---:|---:|---:|
| `Always \`true\` this phase` (target ①) | 1 | **0** | 1 | **0** |
| `the real permission bit is a follow-up` (target ②) | 1 | **0** | 0 |
0 |
| `user-level export slot` (BRIGHT) | 3 | 3 | 1 | 1 |
| `totallyFabricatedNeedleXyz` (DARK) | 0 | 0 | 0 | 0 |

The strongest control is in the table itself: in the post-edit run the
**same** target needle read `0` in the edited source while still reading
`1` in the not-yet-regenerated baseline — one run, one needle, both
answers.

## The regeneration leg — the baseline follows, and the gate says so

⛔ The baseline is not hand-edited. `check:api-surface-declarations` was
run **before** regenerating and named exactly what moved:

```
  data.txt
    ~ ResolveApiOptions (interface)  (declaration text changed)

@objectstack/spec declaration text changed: 0 removed, 0 added, 1 reshaped.
```

`pnpm --filter @objectstack/spec gen:api-surface-declarations` then
reported `touched: data.txt`, and the whole diff in that file is the one
hunk at `ResolveApiOptions`.

Worth recording because it is counter-intuitive: the generator's header
states it deliberately does **not** record a declaration's *leading*
TSDoc. Carrier ① is a **member** docblock *inside* the interface body,
so it is part of the declaration text and does move the artifact;
carrier ② is leading trivia on a `const` and correctly moves nothing.
That is why the baseline shows one hunk and not two, and it is exactly
what the gate reported.

## Every file in this diff × is it published

`@objectstack/spec` `files[]` currently reads
`["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","api-surface-declarations","spec-changes.json"]`.

| file in this diff | on `files[]`? | reading |
|:---|:---|:---|
| `packages/spec/api-surface-declarations/data.txt` | **YES**, directly
| `api-surface-declarations` is a `files[]` entry |
| `packages/spec/src/data/api-derivation.ts` | not as itself; **YES**
through `dist` | the source is not matched by `src/**/*.zod.ts`, but its
TSDoc compiles into the packed `dist/data/index.d.ts`, which carries the
new text and no longer carries the old — verified by grep on the built
tree |
| `.changeset/18991-user-export-slot-is-a-real-optin-grant.md` | no |
changesets do not publish |

⇒ **Two** published surfaces move, so this is ⛔ not `skip-changeset`. It
carries a `patch` changeset for `@objectstack/spec`.

## Verification

All readings at `a95a608623` (this branch merged with `origin/main`
`43f4766889`), exit codes captured before any pipe.

- `pnpm --filter @objectstack/spec test` — **489 files / 14209 tests
passed**, exit 0. The card's own file named explicitly: `vitest run
--project local src/data/api-derivation.test.ts` — **1 file / 32 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec typecheck` — exit 0.
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date**, exit 0, working tree clean afterwards.
- `pnpm lint` (`eslint . --no-inline-config`, whole repo, ⛔ not
narrowed) — exit 0.
- Gate roster from `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: **77 derived, 76 run green, 0 unrun.**
Reconciled with `--ran`, exit 0.
- ⊘ **NOT MEASURED (1):** `pnpm check:dual-build-cjs-loads` refused with
`PREREQUISITE NOT MET` / exit 3 — it reads built output for 87 packages
and needs a whole-repo `pnpm build`, which is CI's `Build Core`. Exit 3
is that gate's declared "nothing was measured" code, ⛔ neither a pass
nor a failure. The two other exit-3 refusals were cleared by building
only their prerequisites and both then ran green
(`check:doc-formula-expressions`, `check:lean-entry-closure`).

## Acceptance notes

- ⛔ Out of scope: objectstack-ai#18931 has its own PR (objectstack-ai#18984). Nothing under
`packages/plugins/**` is touched here; those two readers appear above as
evidence only.
- `permission.zod.ts` is deliberately **not** edited — it is the half
that is already correct, and this change points at it rather than
copying it.
- **Noted, not filed.** The module docblock's "an author who whitelists
`['list']` gets `aggregate`/`export`/`search` for free" is about the
authored-vs-derived vocabulary, not about the permission axis, and it is
true on the default no-user-context path — the same way it is true for
`search`, whose `searchable` flag it also does not caveat. Not a third
carrier; left alone.
- **Noted, not filed.** `api-derivation.test.ts:68`'s title still says
"this phase" (`export = list (this phase, userExportAllowed defaults
true)`). It describes the **default**, which is true today, and touching
it would move a test file in a zero-behaviour-change PR. Carrier: the
next PR that edits that suite.
- ⚠️ **Open question for the seat, deliberately not acted on here:**
after this correction the sentence still has **no guard**. Nothing fails
when the code moves again and the prose rots again, which is precisely
how it rotted this time. Whether to pin it is another card's decision;
it is in the report's `open_questions` and ⛔ was not built here, to keep
this diff at two docblocks.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…incipal too (objectstack-ai#19242)

Fixes objectstack-ai#18990

Clause-②: no

`/auth/me/permissions` stayed completely silent about every object a
principal could reach when that principal's only grant was a wildcard
carrying `viewAllRecords`. `seedSuperUserRestrictedObjects` returned
early unless the wildcard carried `modifyAllRecords`, so the object got
no entry, the client fell back to its default-allow path, and it
rendered write and Export affordances the server answers `403
EXPORT_NOT_PERMITTED`. Same silence and same consequence as the class
objectstack-ai#18931 closed, for a different principal class.

Implements the ruling on this card — batch objectstack-ai#159 item 1, **letter A**,
maintainer 「同意」 2026-09-18T11:42Z, recorded at comment `5729478681`.

## What changed

**One predicate admits both principal classes.** The seed now asks the
wildcard READ bypass rather than the modify bit alone. That question
already existed twice in this file's neighbourhood —
`foldWildcardSuperUser` asks it to decide whose `allowRead` it pulls
true, and `PermissionEvaluator.checkObjectPermission` applies the same
bypass server-side (`viewAllRecords || modifyAllRecords` short-circuits
read; only the modify bit reaches the write axis). It is now a single
module-local reading that the fold and the seed share, so the seed
structurally cannot materialise an entry for a principal the fold leaves
entirely false.

```
seed  -> fold -> clamp -> annotate         (unchanged order, unchanged call site)
{allow*: false}   allowRead: true   writes stay false   apiOperations attached
```

**The export axis needed no new branch.** This was the premise worth
falsifying, since adding a second predicate for one question is the
exact defect objectstack-ai#18931 was about. It holds: the seed resolves
`resolveEffectiveApiMethods` with `userExportAllowed` read off the
wildcard's `allowExport`, annotate resolves it with `acc.allowExport ??
wildExport`, and a seeded entry carries no `allowExport` of its own
while the fold adds none — so the two resolve to the same bit, and both
skip on the same `unrestricted && userExportAllowed`. The ruling's
carve-out ("skip only an unrestricted object whose export stays
allowed") is therefore reached through the predicate that was already
there, not a copy of it.

**A plain wildcard grant carrying neither bypass bit is still not
seeded**, and that is what makes the admission the read bypass rather
than "any wildcard": the fold pulls nothing true for such a principal,
so a seeded entry would be an all-false claim with no server behaviour
behind it. Pinned as its own case.

## ⭐ Two `toBeUndefined` pins are INVERTED on purpose

A reviewer should not have to guess whether a flipped pin was
intentional. Both flips are the ruling being carried out, not
collateral:

1. `does not reach a viewAll-only principal (the seed guard is
unchanged)` — the pin PR objectstack-ai#18984 added, and the one the ruling names by
its text. That PR pinned this boundary deliberately while saying in the
same breath that the pin was **not** a ruling that the silence was
correct. The ruling on this card is what now says otherwise.
2. `does not seed for a viewAll-only wildcard (avoids flipping check()
to explicit deny)` — the older objectstack-ai#3391-era pin on the same principal
class. The ruling names only the first; this one asserts the identical
thing about the identical class, so it moves with it. Flagging it
explicitly because the dispatch order named one pin and the diff flips
two.

The behaviour change they pinned is real and deliberate: for a
viewAll-only principal, a client that read "no entry" as default-allow
now reads an explicit `allowEdit: false`. That is the truth about it —
the seed only ever touches objects with **no explicit entry** (`if
(objects[name]) continue`), and on those a viewAll-only principal
genuinely can only read, while the silence it replaces was not true
about anything.

## Verification

Reverse verification, run from the committed fix and restored byte-exact
(`git hash-object` equal to the HEAD blob, `git diff HEAD` empty).
**Expected direction chosen before the run: the two new POSITIVE pins go
red; the two negative/control pins cannot distinguish and stay green.**
That is what happened — replacing the shared predicate with the old
`modifyAllRecords`-only guard: `Tests 2 failed | 22 passed`, failing
exactly `seeds for a viewAll-only wildcard too` and `reaches a
viewAll-only principal: read folded true, writes explicitly false`. The
two absence-asserting cases stayed green under the old guard, correctly:
absence held there too, so they are controls on the predicate's other
half and not evidence for the fix.

- `pnpm --filter @objectstack/plugin-hono-server test` :: exit 0 — 26
files, 311 passed, 1 todo
- `pnpm --filter @objectstack/plugin-hono-server typecheck` :: exit 0 —
0 errors, 0 pinned signatures
- `pnpm lint` (the full repo union, `eslint . --no-inline-config`) ::
exit 0, at `0625da76a`
- 59 derived gate families via `dispatch-gates --commands --repo
objectstack-ai/objectstack`, reconciled with `--ran` carrying every exit
code: **56 run green, 3 NOT MEASURED, 0 UNRUN**

The three NOT MEASURED are `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:type-check-debt`. Each exits **3**
(PREREQUISITE NOT MET, which is neither a pass nor a failure) because it
reads built output across the whole workspace and this tree has only the
`plugin-hono-server` dependency closure built. CI builds the workspace
before them. A declared narrowing, not a skipped gate.

`check-widening-tells --declaration no` exits 0 but reports all 3 files
NOT MEASURED — no declared surface covers `packages/plugins/**` — so the
`Clause-②: no` above is a structural measurement, not that gate's
clearance: the diff adds no exported symbol (`wildcardGrantsSuperRead`
is module-local), no key on the published payload (the `objects` map
gains **entries**, not keys), no registration and no error code.
`EXPORT_NOT_PERMITTED` appears in this diff only as prose citing the
existing code.

## Acceptance notes

- **Changeset: `patch` on `@objectstack/plugin-hono-server`.** A
behaviour fix in a released package. The body carries the
deliberate-behaviour-change warning for whoever compiles the release
notes, since a consumer reading "no entry" as default-allow sees a
different answer after this.
- Nothing here reaches `packages/spec` — `resolveEffectiveApiMethods` is
imported, not edited. The declared file surface
`packages/plugins/plugin-hono-server/src/` was not breached; the only
file outside it is the changeset this package owes.
- The re-lane the ruling flagged as possible does not materialise: all
three symbols resolve out of `plugin-hono-server`, confirmed again at
this branch's base.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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>
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 domain:cli size/m tests tooling

Projects

None yet

2 participants