Skip to content

docs(spec): the user-level export slot is a real opt-in grant, not "always true this phase" - #19010

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-18991-user-export-always-true-rot
Sep 18, 2026
Merged

os-bill merged 2 commits into
mainfrom
claude/issue-18991-user-export-always-true-rot

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

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 test489 files / 14209 tests passed, exit 0. The card's own file named explicitly: vitest run --project local src/data/api-derivation.test.ts1 file / 32 tests passed, exit 0.
  • pnpm --filter @objectstack/spec typecheck — exit 0.
  • pnpm --filter @objectstack/spec check:generatedall 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: 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 has its own PR (fix(plugin-hono-server): /auth/me/permissions speaks for an unrestricted object when the export axis withholds export #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.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3


Generated by Claude Code

…lways 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 <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation protocol:data tooling labels Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/data.txt), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/data.txt) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 43f4766889e39d7a4590c5787d38e5956d0b4cb6packageMentionDocs.

Which tree this was computed on

This run read content/docs from f2c7f528542fbf7a69ba339066455827578303ad — the merge of head a95a608623eec5e4a7cebcda5e760adcb07bdcae into base 43f4766889e39d7a4590c5787d38e5956d0b4cb6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f2c7f528542fbf7a69ba339066455827578303ad && git checkout f2c7f528542fbf7a69ba339066455827578303ad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43f4766889e39d7a4590c5787d38e5956d0b4cb6 a95a608623eec5e4a7cebcda5e760adcb07bdcae && git checkout -B drift-repro 43f4766889e39d7a4590c5787d38e5956d0b4cb6 && git merge --no-ff a95a608623eec5e4a7cebcda5e760adcb07bdcae

node scripts/docs-audit/affected-docs.mjs --json 43f4766889e39d7a4590c5787d38e5956d0b4cb6

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

@os-bill os-bill added the domain:spec label Sep 18, 2026 — with Claude
@os-bill
os-bill marked this pull request as ready for review September 18, 2026 11:59
@os-bill
os-bill added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 75c0dac Sep 18, 2026
41 checks passed
@os-bill
os-bill deleted the claude/issue-18991-user-export-always-true-rot branch September 18, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants