Commit 75c0dac
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.
- 1 parent 72c1640 commit 75c0dac
3 files changed
Lines changed: 52 additions & 6 deletions
File tree
- .changeset
- packages/spec
- api-surface-declarations
- src/data
Lines changed: 20 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21410 | 21410 | | |
21411 | 21411 | | |
21412 | 21412 | | |
21413 | | - | |
21414 | | - | |
| 21413 | + | |
| 21414 | + | |
| 21415 | + | |
| 21416 | + | |
| 21417 | + | |
| 21418 | + | |
| 21419 | + | |
| 21420 | + | |
| 21421 | + | |
| 21422 | + | |
| 21423 | + | |
| 21424 | + | |
| 21425 | + | |
21415 | 21426 | | |
21416 | 21427 | | |
21417 | 21428 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
129 | | - | |
130 | | - | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
131 | 135 | | |
132 | 136 | | |
133 | 137 | | |
| |||
193 | 197 | | |
194 | 198 | | |
195 | 199 | | |
196 | | - | |
197 | | - | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
198 | 213 | | |
199 | 214 | | |
200 | 215 | | |
| |||
0 commit comments