From 68338e13637e824fa093c7e7724933bce77e48ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 11:09:43 +0000 Subject: [PATCH] docs(spec): the user-level export slot is a real opt-in grant, not "always true this phase" `ResolveApiOptions.userExportAllowed` and the `API_METHOD_DERIVATION` table docblock both asserted, in the present tense, that the slot is always `true` and that no user-level export permission bit exists yet. #3544 wired that bit in: `PermissionSetSchema.allowExport` declares the axis as an opt-in grant (unset or `false` means no export), plugin-security resolves `export` as `list AND userExportAllowed`, plugin-hono-server feeds the resolved bit into `resolveEffectiveApiMethods`, and this package's own suite pins the `false` arm. The two statements could not both be true. Both docblocks now state the axis as it is and name `allowExport` as the authority on its semantics, while keeping the still-true half distinct: an omitted option resolves to `true` because a resolve carrying no permission context must not narrow the object's own exposure. No behaviour change -- the derivation functions are byte-identical and no test assertion moved. `api-surface-declarations/data.txt` is the regenerated baseline, not a hand edit. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- ...-user-export-slot-is-a-real-optin-grant.md | 20 ++++++++++++++++ .../spec/api-surface-declarations/data.txt | 15 ++++++++++-- packages/spec/src/data/api-derivation.ts | 23 +++++++++++++++---- 3 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 .changeset/18991-user-export-slot-is-a-real-optin-grant.md diff --git a/.changeset/18991-user-export-slot-is-a-real-optin-grant.md b/.changeset/18991-user-export-slot-is-a-real-optin-grant.md new file mode 100644 index 00000000000..084dc47715c --- /dev/null +++ b/.changeset/18991-user-export-slot-is-a-real-optin-grant.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': patch +--- + +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) + +`Clause-②: no` + +⛔ **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. + +Both carriers said the same untrue thing, and they said it in a direction that invites reintroducing a defect: + +- `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". +- 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)". + +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`). + +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. + +**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. diff --git a/packages/spec/api-surface-declarations/data.txt b/packages/spec/api-surface-declarations/data.txt index a034ec0d680..bdf043b1be4 100644 --- a/packages/spec/api-surface-declarations/data.txt +++ b/packages/spec/api-surface-declarations/data.txt @@ -21410,8 +21410,19 @@ declare const ReplicationConfigSchema: z.ZodObject<{ interface ResolveApiOptions { /** * 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). + * 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. */ userExportAllowed?: boolean; } diff --git a/packages/spec/src/data/api-derivation.ts b/packages/spec/src/data/api-derivation.ts index f6d5167f799..f1c6fe6959e 100644 --- a/packages/spec/src/data/api-derivation.ts +++ b/packages/spec/src/data/api-derivation.ts @@ -126,8 +126,12 @@ interface DerivationRule { * is refined by `writeMode` in {@link isApiOperationAllowed} * (insert→create, update→update, upsert→create∧update). * - `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). + * (`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). * - `restore`/`purge` map to `delete` but their flag is permanently `false`: * `enable.trash` was retired (#2377/ADR-0049) with no runtime consumer, so * 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 = { export interface ResolveApiOptions { /** * 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). + * 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. */ userExportAllowed?: boolean; }