Skip to content

Commit 75c0dac

Browse files
os-billclaude
andauthored
docs(spec): the user-level export slot is a real opt-in grant, not "always true this phase" (#19010)
Fixes #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. #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 (#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 (#3391 / #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 (#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 (#3391 / #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: #18931 has its own PR (#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>
1 parent 72c1640 commit 75c0dac

3 files changed

Lines changed: 52 additions & 6 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(data): `ResolveApiOptions.userExportAllowed` no longer documents itself as "always `true` this phase" — the user-level export bit is wired, and it is a real opt-in grant that can be `false` (#18991)
6+
7+
`Clause-②: no`
8+
9+
⛔ **No behaviour change.** `isLegacyDerivable`, `computeOperations` and `resolveEffectiveApiMethods` are byte-identical; the omitted-option default is still `true` (`opts?.userExportAllowed !== false`), and not one assertion in `api-derivation.test.ts` moved. What changes is two docblocks in `packages/spec/src/data/api-derivation.ts` that made a **false present-tense claim**, and the generated declaration baseline that reproduces one of them.
10+
11+
Both carriers said the same untrue thing, and they said it in a direction that invites reintroducing a defect:
12+
13+
- `ResolveApiOptions.userExportAllowed` — "Always `true` this phase (there is no user-level export permission bit yet); wiring a real bit in is a zero-contract change".
14+
- the `API_METHOD_DERIVATION` table docblock — "`export` is `list`, additionally gated by the user-level export slot (…, always `true` this phase — the real permission bit is a follow-up, wiring it changes no contract here)".
15+
16+
The bit exists. `PermissionSetSchema.allowExport` (`src/security/permission.zod.ts`) declares the user-level export axis as an **opt-in grant** — `true` grants export, UNSET or `false` means no export — and the two statements cannot both be true. It is not an aspiration either: `plugin-security`'s `permission-evaluator` resolves `export` as `list ∧ userExportAllowed` and returns `false` from that branch, `plugin-hono-server`'s `/me/permissions` computes the bit and hands it to `resolveEffectiveApiMethods`, and this package's own suite has pinned the `false` arm all along (`export gated off when userExportAllowed=false`).
17+
18+
An author who trusted the old text would read the parameter as inert and could legitimately simplify it away as dead weight — which is the same defect one level upstream of where it was last found, with no consumer left to notice. Both docblocks now state the axis as it is, name `PermissionSetSchema`'s `allowExport` as the authority on its semantics, and keep the one thing that *is* still true distinct from the one that is not: omitting the option resolves to `true` because a resolve carrying no permission context must not narrow the object's own exposure — that is what lets `apiExposureDenialReason` remain a pure function of `enable` — while a caller holding permission context passes the resolved bit explicitly.
19+
20+
**Why this publishes rather than taking `skip-changeset`.** Two entries of this package's `files[]` move. `api-surface-declarations/` ships, and the member docblock sits *inside* the `ResolveApiOptions` interface body, so it is part of the declaration text that artifact records (leading TSDoc is excluded; an interior member's is not) — `check:api-surface-declarations` reported the shard stale as `~ ResolveApiOptions (interface) (declaration text changed)`, 0 removed, 0 added, 1 reshaped, and the regenerated `data.txt` carries the new text. `dist/` ships too, and the packed `dist/data/index.d.ts` carries it. A consumer reading either one reads different bytes after this change, so the corrected sentence is what reaches them.

‎packages/spec/api-surface-declarations/data.txt‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21410,8 +21410,19 @@ declare const ReplicationConfigSchema: z.ZodObject<{
2141021410
interface ResolveApiOptions {
2141121411
/**
2141221412
* User-level export permission slot. `export` derives from `list` AND this
21413-
* flag. Always `true` this phase (there is no user-level export permission
21414-
* bit yet); wiring a real bit in is a zero-contract change (#3391 follow-up).
21413+
* flag.
21414+
*
21415+
* The flag carries the user-level export axis — `PermissionSetSchema`'s
21416+
* `allowExport` bit (`../security/permission.zod`, the authority on its
21417+
* semantics). That bit is an OPT-IN GRANT: unset or `false` means NO export.
21418+
* So this flag is genuinely `false` for a real caller whose permission sets
21419+
* withhold the grant, and `export` is withheld with it (#3391 / #3544).
21420+
*
21421+
* Omitting the option is the NO-USER-CONTEXT case and resolves to `true` —
21422+
* a resolve that carries no permissions does not narrow the object's own
21423+
* exposure, which is what lets {@link apiExposureDenialReason} stay a pure
21424+
* function of `enable`. A caller that HAS permission context passes the
21425+
* resolved bit explicitly.
2141521426
*/
2141621427
userExportAllowed?: boolean;
2141721428
}

‎packages/spec/src/data/api-derivation.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,12 @@ interface DerivationRule {
126126
* is refined by `writeMode` in {@link isApiOperationAllowed}
127127
* (insert→create, update→update, upsert→create∧update).
128128
* - `export` is `list`, additionally gated by the user-level export slot
129-
* (`ResolveApiOptions.userExportAllowed`, always `true` this phase — the real
130-
* permission bit is a follow-up, wiring it changes no contract here).
129+
* (`ResolveApiOptions.userExportAllowed`), which carries `PermissionSetSchema`'s
130+
* `allowExport` opt-in grant (`../security/permission.zod`, the authority on
131+
* its semantics): a caller whose permission sets withhold that bit resolves
132+
* `list` WITHOUT `export`. Omitting the slot is the no-user-context case and
133+
* leaves `export` derived (#3544 wired the bit in; plugin-security's
134+
* `permission-evaluator` and plugin-hono-server's `/me/permissions` both feed it).
131135
* - `restore`/`purge` map to `delete` but their flag is permanently `false`:
132136
* `enable.trash` was retired (#2377/ADR-0049) with no runtime consumer, so
133137
* there is no soft-delete state to restore/purge. They return as live derived
@@ -193,8 +197,19 @@ export const DATA_ACTION_TO_API_OPERATION: Record<string, ApiOperation> = {
193197
export interface ResolveApiOptions {
194198
/**
195199
* User-level export permission slot. `export` derives from `list` AND this
196-
* flag. Always `true` this phase (there is no user-level export permission
197-
* bit yet); wiring a real bit in is a zero-contract change (#3391 follow-up).
200+
* flag.
201+
*
202+
* The flag carries the user-level export axis — `PermissionSetSchema`'s
203+
* `allowExport` bit (`../security/permission.zod`, the authority on its
204+
* semantics). That bit is an OPT-IN GRANT: unset or `false` means NO export.
205+
* So this flag is genuinely `false` for a real caller whose permission sets
206+
* withhold the grant, and `export` is withheld with it (#3391 / #3544).
207+
*
208+
* Omitting the option is the NO-USER-CONTEXT case and resolves to `true` —
209+
* a resolve that carries no permissions does not narrow the object's own
210+
* exposure, which is what lets {@link apiExposureDenialReason} stay a pure
211+
* function of `enable`. A caller that HAS permission context passes the
212+
* resolved bit explicitly.
198213
*/
199214
userExportAllowed?: boolean;
200215
}

0 commit comments

Comments
 (0)