Repository navigation
fix(plugin-hono-server): /auth/me/permissions speaks for an unrestricted object when the export axis withholds export - #18984
Conversation
…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>
…-permissions-unrestricted-export
…-permissions-unrestricted-export
📓 Docs Drift Check1 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
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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 |
|
ACCEPT · 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
⭐ The docs axis a bot cannot see, asked by hand.
⇒ 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 — ruledQ1 — clause-② reading: (A), Q2 — entry shape: (A), as shipped. The CRUD answer is unchanged in substance — explicit Findings filedBoth deduped first against a complete enumeration of the 529 open cards with a lit control beside the probes (
⛔ Not adopted as a correctionYour note that the PR body states the conservative first-pass gate reading (3 NOT MEASURED) while the reconciled reading is
What remainsCI on Generated by Claude Code |
…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>
…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>
…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>
Fixes #18931
Clause-②: no
/auth/me/permissionsnow reports an unrestricted object's effective operation set whenever the export axis withholdsexport, so the Console stops rendering an Export button the server answers403 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/mainThe card pinned
@objectstack/*17.4.0 atobjectstack main@02bdeaaf. That anchor has moved; the behaviour has not. Re-derived at89c6ec52b5by driving the endpoint's own four passes — seed, fold, clamp, annotate — with the card's principal verbatim ('*'carrying the super-user bits and noallowExport):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.
seedSuperUserRestrictedObjectsresolved each schema without the export slot and skipped everyunrestrictedone:annotateEffectiveApiOperationsresolves 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
apiMethodskey. The card's 29/9 is HotCRM's, and HotCRM is not in this repo. In this repo's own corpus: 26 of 26examples/**/*.object.tsare unrestricted, andapiMethodsis a platform convention (44 of 48packages/platform-objectsobjects 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.enableshapes an unrestricted object can carry: withholdingexportsubtractsexportand nothing else, andmodestaysunrestrictedin 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 amode-only guard could never tell the two cases apart — the discriminator isuserExportAllowed, notmode.better-auth,engine-owned,append-only) hold 60 objects in this tree and all 60 already declareapiMethods, 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 adoptmanagedBywithoutapiMethodstomorrow.The change
The seed now applies annotate's own predicate:
A seeded entry carries no
allowExportof its own andfoldWildcardSuperUserdoes not add one, so annotate'sacc.allowExport ?? wildExportresolves 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.canExportstill answers 403); no request that was refused is now accepted.The
modifyAllRecordsguard is unchanged. #18931 widens which schemas are considered, never which principals — a viewAll-only caller still gets nothing, because materialising afalseentry 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
apiMethodsdeclaration. Pinned inpackages/plugins/plugin-hono-server/src/effective-api-operations.test.ts, asserting pedigree and not a count: the entry exists, itsapiOperationsomitsexport, and it equals the full unrestricted closure minusexport— 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):
*.e2e.test.*,*.live.test.*).node scripts/nightly-tiers.mjs --packagesreports one package owns nightly-tier files —@objectstack/cli, 68 of them.plugin-hono-serverowns zero, and the negative direction is mechanical too: grepping that output forplugin-hono-serverreturns 0.plugin-hono-server'svitest.config.tsdeclares a single unnamed project — there is no--projectnarrowing in this package at all, sopnpm testis the whole suite, exactly as CI runs it.Test Core'sturbo ls --affectedset because this diff edits itssrc/. ⇒ 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 onlyassertedobjects.open_objundefined with a wildcard carrying noallowExport— so after #3544 it was reading the export axis while naming theapiMethodsderivation. It now grantsallowExport: 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: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:
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: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 ⇒
patchchangeset, andskip-changesetwould 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 atorigin/mainafter merging (the first derivation warned STALE TREE and was discarded): 59 commands, 56 exit 0. Three reportPREREQUISITE NOT METand 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-tellswas 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:enforceExportPermissionandsecurity.canExportare untouched; nothing refused before is accepted now. The diff touches no request-acceptance path.packages/spec/src/**path.apiOperationsis 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.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:250listsplugin-hono-serververbatim underdomain:cli, and the fix landed entirely inpackages/plugins/plugin-hono-server/src/.plugin-securityandplugin-authwere 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:
modifyAllRecordsguard — the card's own Options §2, which it defers ("Follow-up; not required"). This PR closes finding(plugin-hono-server): /auth/me/permissions never seeds an unrestricted object for a wildcard-only principal, so the Console renders Export where the server answers 403 EXPORT_NOT_PERMITTED #18931, so that half would otherwise lose its home; handed to the seat to file rather than filed here.ResolveApiOptions.userExportAllowed's docstring inpackages/spec/src/data/api-derivation.tscontradicts shipped behaviour — it reads "Alwaystruethis phase (there is no user-level export permission bit yet)", which 用户级 export 权限轴(接入 P1 预留的 userExportAllowed 槽) #3544 falsified when it wired the bit in. An author trusting it would conclude the export axis is inert and could simplify the parameter away, which is the shape of this very incident. It is inpackages/spec(a different lane) and outside this card's surface; handed to the seat.Two things could not be measured here and are reported as such, not as passes:
objectui packages/permissions/src/MePermissionsProvider.tsxgetObjectApiOperations("Absent → undefined so consumers fall back to their current (default-allow) path").objectstack-ai/objectuiis 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.🤖 Generated with Claude Code
https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Generated by Claude Code