Skip to content

fix(hono-server): seed /me/permissions for a viewAll-only wildcard principal too - #19242

Merged
os-tesla merged 3 commits into
mainfrom
claude/issue-18990-viewall-only-permissions-seed
Sep 20, 2026
Merged

os-tesla merged 3 commits into
mainfrom
claude/issue-18990-viewall-only-permissions-seed

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #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 #18931 closed, for a different principal class.

Implements the ruling on this card — batch #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 #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 fix(plugin-hono-server): /auth/me/permissions speaks for an unrestricted object when the export axis withholds export #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 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #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

…incipal too

`seedSuperUserRestrictedObjects` was guarded to `modifyAllRecords`, so a
principal reaching an object only via a wildcard `viewAllRecords` grant got no
entry at all: the client fell back to default-allow and rendered write and
Export affordances the server answers 403.

Admit both super-user classes through one shared reading of the wildcard READ
bypass (`wildcardGrantsSuperRead`), the same question `foldWildcardSuperUser`
already asks and the same one `PermissionEvaluator` applies server-side. The
seeded entry starts all-false, the fold pulls `allowRead` true, the write bits
stay false, and `annotateEffectiveApiOperations` attaches `apiOperations`
through the predicate already shared with the modify-all class.

Ruling: batch #159 item 1, letter A. Two `toBeUndefined` pins on the
viewAll-only class are inverted deliberately under it.

Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 7 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 847e5773a494aa0b495aee9c72528490cff1cdcf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b398e25dd69706af7ddab657a874b4bebb9387c0 — the merge of head 0625da76a140de07fcedb2e4d77a2d1133c81ba1 into base 847e5773a494aa0b495aee9c72528490cff1cdcf, 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 b398e25dd69706af7ddab657a874b4bebb9387c0 && git checkout b398e25dd69706af7ddab657a874b4bebb9387c0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 847e5773a494aa0b495aee9c72528490cff1cdcf 0625da76a140de07fcedb2e4d77a2d1133c81ba1 && git checkout -B drift-repro 847e5773a494aa0b495aee9c72528490cff1cdcf && git merge --no-ff 0625da76a140de07fcedb2e4d77a2d1133c81ba1

node scripts/docs-audit/affected-docs.mjs --json 847e5773a494aa0b495aee9c72528490cff1cdcf

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

Copy link
Copy Markdown
Collaborator

Landed by another seat under a direct maintainer instruction — audit record. Acted by the domain:ui#2 execution seat at objectstack-ai/objectui (PM session session_018HrVaotisyhgmot9o2MLRq, identity os-tesla) at 2026-09-20T08:39Z. ⚠️ This is ⛔ not this seat's lane: the card is domain:cli, held by seat #6024.

The three things this kind of act owes: whose instruction, its words, and where

  • Whose: the maintainer, speaking to this seat directly.
  • Original words, quoted and ⛔ not translated: 「6小时之前的pr,帮我确认什么情况,帮我跟进到合并」
  • Where: this PM session's own chat, 2026-09-20T08:36Z.

What was found

This PR was finished and green five hours before the instruction, and nothing had moved it:

reading value
created 2026-09-20T03:28:13Z
dev report on objectstack#18990 2026-09-20T03:29:50Z — status: done
checks on 0625da76a 34 runs: 31 success · 3 skipped · 0 failure
review none — the owning seat never posted an accept
state still draft, auto-merge off
the owning seat's last own output seat post objectstack#6024, 2026-09-20T02:57:06Z; its last act anywhere is the accept it wrote on objectstack#18893 at 2026-09-20T03:36:24Z

⇒ two finished PRs (this one and objectstack#19241) were left parked when that seat went quiet. ⛔ This is ⛔ not a reclamation of the seat — the inactivity rule is a day, and it is five hours. Only these two PRs were touched, because only they were named.

The content review this seat did before flipping it — ⛔ not a rubber stamp

The change admits both super-user bypass bits to the seeding pass (viewAllRecords OR modifyAllRecords) through one shared predicate, where it previously admitted modifyAllRecords alone. The old comment's objection — seeding a false entry flips the client's check('edit') from 「undefined → default-allow」 to 「explicit false → deny」 — is answered in the diff rather than dropped: the seed only touches objects with no explicit entry, and on those a viewAll-only principal really can only read.

The load-bearing claim was checked against the server's own evaluator rather than taken from the PR body: packages/plugins/plugin-security/src/permission-evaluator.ts states 「view → the READ bypass: viewAllRecords OR modifyAllRecords」 and implements it at the wildcard resolver and at the allowRead short-circuit. ⇒ the seeded entry is exactly as broad as real enforcement, ⛔ never broader — which is the property that makes this safe.

check-governed-merges.mjs on the final 3-path file list: 0 of 3 hit the register ⇒ NOT governed.

What this seat did, and did not

  • Flipped ready and armed auto-merge; the PR entered the merge queue at 2026-09-20T08:39:15Z.
  • ⛔ Submitted no approval review, ⛔ merged nothing by hand, ⛔ changed no label, ⛔ touched no other card of that seat's.
  • ⚠️ The sibling PR objectstack#19241 is red on Check Changeset and this seat could not clear it: the remedy the gate itself prescribes for a release-nothing diff is the skip-changeset label, and this session's tooling refuses that write as a CI bypass. It is reported back to the maintainer rather than worked around.

Generated by Claude Code

Merged via the queue into main with commit 215840f Sep 20, 2026
36 checks passed
@os-tesla
os-tesla deleted the claude/issue-18990-viewall-only-permissions-seed branch September 20, 2026 09:02
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…gineCanRollBack, not the engine-only probe (objectstack-ai#18997) (objectstack-ai#19286)

Fixes objectstack-ai#18997

Clause-②: no

`getDiscovery()` derived `capabilities.transactionalBatch` from the
ENGINE alone — `typeof this.engine?.transaction === 'function'` — while
`runAtomicBatch` refuses `batchData({ atomic: true })` with `501
NOT_IMPLEMENTED` on `engineCanRollBack(this.engine)`, which asks the
DEFAULT DRIVER as well. `engine.transaction` is a function on every real
engine, so `/discovery` answered `true` for exactly the compositions
that then 501 — and the 501's own remedy text sends the caller to that
bit ("probe `capabilities.transactionalBatch` on /discovery first"). The
prescribed remedy routed the caller to a signal that was wrong in the
only case the remedy exists for.

One line at the read site: the advertisement now asks the predicate the
refusal already trusts.

## What a consumer that sees `true` today will see after this lands

`/discovery` is a published surface, so this is stated rather than
assumed.

Two compositions stop advertising `true` and begin advertising `false`.
Both already answered `501 NOT_IMPLEMENTED` to an atomic batch before
this change, so **nothing that was accepted becomes refused** — only a
lying value becomes honest:

- **(a) a default driver with no `beginTransaction` at all** —
pre-existing on `main`, not introduced by objectstack-ai#18063;
- **(b) a default driver that inherits `beginTransaction` and declares
`supports.transactionsUnsupported`** — the population objectstack-ai#18063 added. The
shipped example is `TursoDriver` on its remote transport
(`packages/drivers/driver-turso/src/turso-driver.ts`,
`transactionsUnsupported: this.transportMode === 'remote'`).

A client that read `true` and proceeded was taking the 501; it now reads
`false` and takes its non-atomic fallback ahead of the failure, which is
what probing the capability was for. A client that hard-asserts
`transactionalBatch === true` at startup against such a composition now
fails at startup instead of at its first atomic batch. The REST
producer's own AND with `api.enableBatch`
(`packages/rest/src/rest-server.ts`) is unchanged and inherits the
corrected input.

Everything else is byte-identical: the key, its type, its `description`
and its place in the closed capability vocabulary do not move.

## Clause-② — measured, not argued

The claim comment grades this `no` on the ground that it narrows. The
falsifier was to be measured, not assumed: if any composition advertised
`true` where `origin/main` advertises `false`, that would be a widening.

Enumerated over all 16 compositions of the four inputs the two
predicates can read (engine has `transaction`; driver registry
inspectable; driver has `beginTransaction`; driver declares
`transactionsUnsupported`), comparing `main`'s derivation with this one:

```
compositions: 16 | widened (false -> true): 0 | narrowed (true -> false): 3
VERDICT: NO WIDENING — accept set untouched, advertisement narrows only
```

The 3 narrowed rows are population (a) x2 (driver has no
`beginTransaction`, with and without the declaration) and population (b)
x1. `Clause-②: no` holds.

## Both directions, or the card is not delivered

"A fix that makes the flag honest by making it always `false` is worse
than the bug." So the TRUE arm is pinned beside the FALSE arm, and each
composition is driven through `batchData({ atomic: true })` in the same
test — the advertisement and the refusal have to agree composition by
composition.


`packages/metadata-protocol/src/protocol.discovery-transactional-batch-honesty.test.ts`
(6 cases):

- a default driver that CAN roll back: still `true`, and the atomic
batch commits;
- a host whose driver registry is not inspectable (test doubles,
metadata-only hosts): still `true` — the engine-level probe is all there
is, which is what `engineCanRollBack` documents;
- population (a): `false`, and the 501 arrives with the remedy sentence
this card is about, having attempted no write;
- population (b): `false`, same refusal;
- no `transaction()` at all: `false` (unchanged);
- and the rule itself — the advertised bit equals `engineCanRollBack()`
for every composition, so a future composition that satisfies one end
and not the other reds here even if nobody adds a case for it.

**Against the shipped driver**, measured on the built `dist/` after the
fix, not inferred:

```
real MemoryDriver: typeof beginTransaction = function | supports.transactionsUnsupported = undefined | driverSupportsTransactions = true
/discovery capabilities.transactionalBatch = {"enabled":true}
```

The default dev composition keeps its `true`.

## Evidence

- **Pre-fix reproduction.** The pin file was written first and run
unmodified against unmodified `protocol.ts`: `Tests 3 failed | 3 passed
(6)` — the two population cases and the predicate-agreement case red,
the TRUE-arm controls already green. Post-fix: `Test Files 1 passed (1)
· Tests 6 passed (6)`.
- **Ablation** through `scripts/ablation-replace.mjs`, from the
committed state, with on-disk proof: anchor `1 -> 0`, blob `0b04d1ff5d3a
-> 7c63e7631f05`, then `3 failed | 3 passed`; restore verified `blob ==
HEAD (0b04d1f)` and `git diff HEAD` empty. No `dist` preflight is
owed: the pin imports `./protocol.js`, so the mutation is on the
resolution path directly.
- **Package suite** `pnpm --filter @objectstack/metadata-protocol test`:
`Test Files 181 passed | 3 skipped (184) · Tests 2591 passed | 19
skipped (2610)`. `typecheck` exit 0, and `tsc --noEmit --listFiles`
names the new pin file among the 750 files in the program, so the type
check really covers it.
- **Cross-package consumer**
`packages/objectql/src/protocol-discovery.test.ts` against the rebuilt
`metadata-protocol` dist: `32 passed (32)` — including the
real-`ObjectQL`-engine pin that must keep advertising `true`.
- **Gates**, derived from the actual diff with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` and reconciled with `--ran`: 59 derived, **56 run, all exit
0**, 3 NOT MEASURED. The 3 exited `3` (PREREQUISITE NOT MET, nothing
measured) because they read a whole-workspace `dist/` this checkout does
not carry — `check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`; CI's `Build Core` supplies the prerequisite
they refuse without. `check:plugin-teardown-shape --self-test` also
exited 3 at first (its fixture commit is unreachable in a shallow clone)
and exits 0 after fetching that commit.
- **`pnpm lint`** (repo-wide `eslint . --no-inline-config`): exit 0.

All readings are from `objectstack-ai/objectstack` at this branch's head
commit.

## Acceptance notes

- `publishPackageDrafts` in the same file keeps an engine-only probe
(`typeof this.engine?.transaction === 'function'`) for its own `inTxn`
helper. Noted, not filed, and deliberately not changed here: it
advertises nothing to a caller, and both of its branches deliver the
same guarantee today — `engine.transaction()` takes its declared
non-transactional path (ADR-0119 D1) for exactly the drivers the probe
would have excluded, so swapping the predicate there is a no-op in
behaviour. It would become a real question only for whoever makes
draft-publish atomicity a promise rather than a best effort.
- The runtime dispatcher's hardcoded `transactionalBatch: { enabled:
false }` (`packages/runtime/src/http-dispatcher.ts`) is correct and
untouched: that face mounts no `/batch` route.
- Scope held to `packages/metadata-protocol/src/` plus the changeset.
`packages/spec` is not touched — the spec half of objectstack-ai#18063 is landed and
correct.
- `origin/main` moved two commits while this was in flight (`objectstack-ai#19162`
checklist-status workflow, `objectstack-ai#19242` hono-server). Neither touches this
package; the `dispatch-gates.mjs` change in that range is to its own
self-test roster and derives the same 59 families for this diff. Not
merged down; the merge queue rebuilds on current `main`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] /auth/me/permissions is silent for a wildcard-only viewAllRecords principal too — the class #18931 fixed only for modifyAllRecords

3 participants