Skip to content

fix(plugin-auth): GET /organization/list-user-invitations honours the declared requireEmailVerificationOnInvitation - #16730

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-16569-list-user-invitations-verification
Sep 8, 2026
Merged

fix(plugin-auth): GET /organization/list-user-invitations honours the declared requireEmailVerificationOnInvitation#16730
os-zhuang merged 5 commits into
mainfrom
claude/issue-16569-list-user-invitations-verification

Conversation

@os-trump

@os-trump os-trump commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16569

What

AuthManager constructs better-auth's organization plugin with requireEmailVerificationOnInvitation: false on purpose (no mailer wired ⇒ nothing can ever verify an invitee ⇒ requiring verification dead-ends every invite flow). The pinned better-auth 1.7.2 reads that option on accept-invitation, reject-invitation and get-invitation (all three via shouldRequireVerifiedEmailForInvitationIdAction, whose first line returns the declared value), but its listUserInvitations handler refuses every unverified session unconditionally — it never reads the option. So on exactly the deployment shape the declaration exists for, an invitee could accept an invitation they were handed and never list it, and the SDK's organizations.invitations.listMine() inbox was empty-by-403 for every user.

This PR makes GET /organization/list-user-invitations honour the declared option, the ruled shape (a). The endpoint is rebuilt in place on the organization plugin's own endpoints record, under the vendor's own key, from the vendor endpoint's own options object (same path, method, query schema, use: [orgMiddleware], OpenAPI entry), with one predicate changed: the verification refusal is asked against the declared option instead of assumed. Everything else in the handler is the vendor's, in the vendor's order.

Files: packages/plugins/plugin-auth/src/list-user-invitations-verification.ts (the rebuild + the predicate, header carries the full reading), the wiring in auth-manager.ts (the options literal becomes a named satisfies OrganizationOptions object so the very object the vendor was constructed with is what the rebuilt endpoint hands to getOrgAdapter), list-user-invitations-verification.test.ts, a foreign-vocabulary row in packages/runtime/src/dispatcher-error-vocabulary.ts for the restated vendor code, the engine-double ledger learning the new suite, and a patch changeset.

The security gate — measured, not quoted

Real AuthManager → real better-auth 1.7.2 organization plugin → real ObjectQL adapter over the package's memory-engine double; no mailer; fixture users enter through the audience gate's invitation carve-out and are asserted unverified (email_verified false) throughout. Owner invites b@example.com into two organizations and c@example.com into one; B signs up.

Request, as the same unverified invitee B before this PR after this PR
GET /organization/get-invitation?id=… (B's own) 200 200
POST /organization/reject-invitation (B's own) 200 200
POST /organization/accept-invitation (B's own) 200, membership written, B still unverified 200
GET /organization/list-user-invitations 403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION 200, rows addressed to b@example.com only
GET /organization/list-user-invitations?email=c@example.com (client-side) 400 "cannot be passed for client side" 400, unchanged (vendor guard, asked first)
GET /organization/list-user-invitations, no session, no email 400 400, unchanged
the same listing after B is marked verified 200 200, byte-for-byte equal to B's unverified answer

The pre-fix column is the suite's first run against the unmodified route (4 red on the 403, the premise test green); the post-fix column is the same suite at 16/16.

Both stop conditions of the dispatch were checked and neither holds: (1) accept / reject / get-invitation are already permitted to this session on this shape — the premise test is the first test in the file and it passed before any change; (2) the fix returns nothing beyond what the declared option already grants — the parity test pins the unverified answer equal to the verified answer, and the scope pins below bound it.

Own-email pin, stated positively. The listing is the vendor's own exported getOrgAdapter(ctx.context, options).listUserInvitations(session.user.email) followed by the vendor's own status === "pending" post-filter. This module writes no query, joins nothing and reads no other table. The suite pins: C's invitation is never in B's inbox; every row in B's inbox is addressed to B (the audience-gate seed row included — it is a real pending invitation to B); a client-side ?email= is still refused with the vendor's 400 before the session is consulted; a request with neither session nor email keeps the vendor's 400. "List by organization" and "fetch by id" are not reachable from this route by construction.

The triage hard stop ("if the only way is to compute the list yourself in a before-hook, stop") does not trigger: the vendor exports getOrgAdapter from better-auth/plugins/organization, so the one definition of "which invitations may this session see" stays the vendor's and is called, not copied.

Why this shape, and why not a before-hook

  • Precedent, maintainer-ruled: admin-impersonate-endpoint.ts replaces a vendor endpoint on the vendor plugin's own endpoints record, rebuilt from the vendor's own options, so exactly one plugin registers the path (checkEndpointConflicts logs nothing) and auth.api.listUserInvitations is this endpoint.
  • A global before-hook that answered the listing itself would detach every after-hook from the route. Measured in the installed dist/api/dispatch.mjs: when a before-hook returns a non-context object, dispatch returns toResponse(before, …) and never reaches runAfterHooks — the bearer() plugin's set-auth-token echo and every ObjectStack after-hook would silently stop firing on this path. Rebuilding the endpoint keeps the full pipeline.
  • The request contract is not retyped. createAuthEndpoint(path, vendor.options, handler) — the suite pins method, query and metadata by object identity against the vendor endpoint (better-call's createEndpoint.create shallow-copies the record only to append its base middleware to use; every vendor middleware is still present).
  • Declared option only. false → open to an unverified session; true → refused, byte-identical to today; undeclared or non-boolean → refused, the vendor's own list-route posture. The siblings derive an undeclared value from hasBuiltInOpaqueInvitationIdGeneration(…), a vendor internal this module deliberately refuses to re-implement — honouring what is declared restores declared = enforced; re-deriving a default would be a second definition of a security posture.
  • Vendor-drift doors, both loud. At construction, a plugin without the endpoint at that path is left untouched, reported false, and logged at warn (AGENTS.md's one-question rule: the inbox visibly answers a 403, nothing claims a persistence it did not perform — a functional degradation, not a durability one; this deliberately differs from the impersonate precedent's error). In the suite, a vendor pin reads the installed crud-invites.mjs and asserts the three siblings still call the option reader (count 3) while the listing still carries the unconditional refusal verbatim — an upstream fix turns that pin red and this module is what gets deleted.

Clause-②: yes — needs:contract-review on both carriers

Re-derived from the route contract and the export surface, not from the label of "bug fix":

  • Accept set of a published route changed. GET /organization/list-user-invitations (ledgered disposition: 'sdk', client organizations.invitations.listMine) answered 403 for every unverified session and now answers 200 for the ones the deployment declared exempt. That is the reference's conformance class — an input class re-chosen between two published codes — which is judgement, not mechanics, and the tier tool's own line says a card that changes contract accept/reject behaviour is contract-review territory. The provisional read stands: yes.
  • Export surface: unchanged. The new module is not re-exported from src/index.ts or the ./rate-limit-storage entry (grep exit 1 on both); no new key on any published payload — the response is the vendor's own OpenAPI array untouched; no new error code (the vendor's EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION is classified foreign-vocabulary, same as the impersonate rows).
  • Mechanical widening tells: node scripts/pm/check-widening-tells.mjs --declaration no --diff … → "4 changed file(s) read, no widening tell on any declared surface" (exit 0). So the mechanical floor would accept no; the accept-set change is why the declaration is yes anyway.

Verification

  • Suite: pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/list-user-invitations-verification.test.tsTests 16 passed (16), VERDICT command-exit 0.
  • Whole package: pnpm --filter @objectstack/plugin-auth test on 85c101db0Test Files 104 passed (104), Tests 2195 passed (2195), VERDICT command-exit 0. The two later commits touch only packages/runtime/src/dispatcher-error-vocabulary.ts (a gate-ledger string; git diff --stat 85c101db0 HEAD names that one file), and plugin-auth does not depend on @objectstack/runtime, so the reading carries to the head.
  • Typecheck: pnpm --filter @objectstack/plugin-auth typecheck (tsc --noEmit + examples config + check:test-typecheck) on the head → VERDICT command-exit 0, check:test-typecheck: OK (no new test-layer debt). @objectstack/runtime's own tsc is NOT MEASURED locally (its dependency closure is not built here; check:type-check-debt says so with exit 3); the vocabulary edit is one string literal inside an existing array, the vocabulary gate parses the file and finds the row, and tsx imports the module (5 exports, row hit 1). CI's TypeScript Type Check job covers it.
  • Ablation (reverse verification), from the committed state: mutate the one changed predicate (return options.requireEmailVerificationOnInvitation !== false;return true; /* ABLATION-16569 */), on-disk proof orig-count 1→0, marker 0→1, mutated blob 87f578aa… vs HEAD blob 9c788674…; re-run → 5 red / 11 green: red are exactly the reported bug, the own-email scope pin, pending-only, parity and the declared false predicate case; green are the premise, the ?email= and no-session guards, the endpoint-shape pins and the vendor pin. Restore git checkout HEAD -- ABSOLUTE_PATH under a trap; proven by on-disk blob 9c788674… equal to HEAD: blob and git diff HEAD empty.
  • Gate union (scripts/pm/dispatch-gates.mjs, derived from the merge base, --repo objectstack-ai/objectstack), run after the final commit on c62690f75: 66 derived / 66 run / 64 exit 0 / 2 exit 3 = PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:type-check-debt — both read the whole workspace's built dist/, which is not built in this worktree; NOT MEASURED locally, CI's Lint & Repo Gates runs them on a full build) / 0 exit 1; --ran reconcile: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED", exit 0. The families the last two commits added to the derivation (the engine-double ledger and the runtime vocabulary file pulled in 9 more, 57 → 66) were all run. The previous head's one red, check:doc-authoring (a tracker id inside a runtime string), is green here after the string was fixed.
  • eslint, narrowed and proven: pnpm exec eslint --no-inline-config --format json over the 4 changed source files → 4 files, 0 errors, 0 warnings. Population read from eslint.config.mjs (the **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} block minus NEVER_LINTED, plus the packages/** blocks); invariance: the config's own comment states no parserOptions.project and no typed rules for any block, so this diff cannot move an untouched file's verdict. Repo-wide pnpm lint is CI's run.
  • Other reads: check:nul-bytes OK (8256 files); control-byte grep over the touched files exit 1 (none); check:cross-package-test-inputs green (the vendor-source pin locates the package via the manifest-name findUp spelling and its read lands in node_modules).

Changeset

.changeset/list-user-invitations-declared-verification.md@objectstack/plugin-auth: patch (a bug fix in a released package; not skip-changeset). @objectstack/runtime is not bumped: the only change there is a classification row in the dispatcher error vocabulary, a gate ledger with no runtime behaviour; the precedent that added rows to the same file for plugin-schema-ui-required-keys did not bump runtime either.

Upstream (b) — proposed better-auth report, not part of this card's delivery

listUserInvitations ignores requireEmailVerificationOnInvitation. In plugins/organization/routes/crud-invites (1.7.2) acceptInvitation, rejectInvitation and getInvitation all gate the unverified-session refusal on shouldRequireVerifiedEmailForInvitationIdAction({ organizationOptions, … }), which returns organizationOptions.requireEmailVerificationOnInvitation when it is declared. listUserInvitations throws FORBIDDEN / EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION for any session with user.emailVerified === false unconditionally. With the option declared false, an invitee can therefore accept an invitation but never list their own pending invitations. Expected: the listing consults the same predicate as its three siblings.

验收备注

  • noted, not filed: admin-impersonate-endpoint.ts logs its vendor-drift fallback at console.error; under AGENTS.md's degradation-level rule that case (a visible refusal, nothing claiming persistence) reads as warn. This PR uses warn for the same door and leaves the precedent's level alone.
  • noted, not filed: three suites (auth-manager.test.ts, auth-email-locale.test.ts, verification-email-failure-propagation.test.ts) mock better-auth/plugins/organization with a plugin double that carries no endpoints, so the construction-time rebuild never runs there and each construction now prints the vendor-drift warn (silenced by their existing console.warn spies). The rebuild path is exercised by the real-pipeline suite instead.
  • noted, not filed: rebuilding from a vendor endpoint's options via createAuthEndpoint appends better-auth's base optionsMiddleware a second time to use (measured: 2 → 3 entries). Harmless (the middleware is a no-op return), and the impersonate precedent has the same shape.
  • The vendor defect itself belongs upstream — text above.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37


Generated by Claude Code

…mailVerificationOnInvitation

better-auth 1.7.2's listUserInvitations refuses every unverified session
unconditionally, while accept / reject / get-invitation read the option
AuthManager declares false. Rebuild the endpoint in place on the
organization plugin's own endpoints record from the vendor's own options
object, with the verification refusal asked against the declared option;
the listing stays the vendor's getOrgAdapter(...).listUserInvitations
(session email, pending only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ity, not options identity

createAuthEndpoint shallow-copies the options record to append its base
middleware, and the vendor's $ERROR_CODES entry carries a toString helper;
pin method/query/metadata by identity and code/message by value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
- classify the restated vendor code as foreign-vocabulary (dispatcher
  error vocabulary row, same shape as the impersonate precedent's)
- let the engine-double ledger record the new suite's doubles
- apply the fake engine's limit by presence, not truthiness
- log the vendor-drift fallback at warn: a visible 403 is a functional
  degradation under AGENTS.md's one-question rule, not a durability one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
check:doc-authoring — a runtime string reaches readers who cannot resolve
an issue id; the anchor lives in git history and the source comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 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.

Coarse fallback — 31 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 7f96e1417e01d011884272b28278b6b400521415packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7f96e1417e01d011884272b28278b6b400521415

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

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16730 @ c62690f

Verdict: PASS WITH FINDINGS — the contract change itself (the accept set of GET /organization/list-user-invitations) is exactly the ruled shape (a), widens nothing beyond the session's own email, and is pinned by tests that go red on reversal. Landing is blocked by one red CI gate whose only honest fix is ⛔ MAINTAINER-ONLY (F1), and by the fact that the "ruling" the PR cites is the dispatch seat's, not a maintainer's (F2).

Ruling implemented: yes (shape (a), as ruled by the dispatch seat; no maintainer ## Ruling recorded comment exists on #16569 — see §1 and F2).

Everything below was read from the PR head (refs/pull/16730/head = c62690f75), the installed better-auth 1.7.2 dist, and the repo's own gate sources — not from the PR body.

1. The ruling and the triage hard stop

#16569 carries four comments. None is titled ## Ruling recorded. The shape ruling is in the dispatch seat's Claim comment (os-trump, COLLABORATOR, 2026-09-08T01:02Z), verbatim:

席位裁定修复形状:(a) 在 ObjectStack 侧让该路由遵守已声明的选项

  • (a) —— 让 /organization/list-user-invitations 遵守 requireEmailVerificationOnInvitation,与 accept / reject / get-invitation 三条路由已经遵守它的方式一致。
  • (c) 不采纳 —— 「把收件箱记为仅已验证用户可用」是收窄一个已声明的能力……收窄已声明能力是维护者的判断,⛔ 不是 dev 顺手改文档能定的。
  • (b) 可并行但不替代 —— 上游报告不修我们用户的问题。

The same comment's stop condition: "如果你测出 accept/reject 在这个部署形状下并非已经放行,或者你的修复会让调用方拿到超出该已声明选项已授予范围的任何东西(例如列出不属于该 session 邮箱的邀请),那么前提就崩了". And: "列出的结果只能是该 session 邮箱自己的邀请……测试要正面钉这一点(另一个用户的邀请不可见)。"

The triage hard stop (os-zhuang, MEMBER, 2026-09-07T22:27Z), verbatim:

⚠️ 硬停机条件,这条最重要:若唯一的落地方式是在 before-hook 里自己把清单算出来,那就等于给「哪些邀请该被这个用户看到」造了第二份定义——而那是一个安全相关的筛选。⇒ 停下来在卡上报,由我路由裁定。⛔ 不要因为「让它一致」听起来无害就顺手复刻一份筛选逻辑。

Shape (a): implemented exactly. The route now consults the declared option; the siblings' behaviour is untouched; (c) was not taken (no docs narrowing); (b) is text in the PR body only. Hard stop respected: the rebuilt handler does not compute the list — it calls the vendor's exported getOrgAdapter(ctx.context, options).listUserInvitations(userEmail) (exported from better-auth/plugins/organization/index.mjs line 4: export { getOrgAdapter, hasPermission, organization, parseRoles }) and applies the vendor's own status === 'pending' filter. No before-hook was written.

2. Security gate — verified from the tree, not the PR body

Vendor handler, installed 1.7.2 dist/plugins/organization/routes/crud-invites.mjs, in order: getSessionFromCtxif (ctx.request && ctx.query?.email) throw 400if (session && !session.user.emailVerified) throw FORBIDDENuserEmail = session?.user.email || ctx.query?.emailif (!userEmail) throw 400getOrgAdapter(ctx.context, options).listUserInvitations(userEmail).filter(status === "pending")ctx.json(...).

Rebuilt handler, list-user-invitations-verification.ts lines 171–202, line by line against that:

  • (a) Same query, same table, same filter. The only data read is getOrgAdapter(ctx.context, options).listUserInvitations(userEmail); the vendor adapter (adapter.mjs 715–727) is findMany({ model: "invitation", where: [{ field: "email", value: email.toLowerCase() }], join: { organization: true } }). The module writes no query of its own and reads no other table. ✅
  • (b) One predicate changed. if (session && !session.user.emailVerified && listingRequiresVerifiedEmail(options)) where listingRequiresVerifiedEmail = options.requireEmailVerificationOnInvitation !== false. So false → open; true / undefined / non-boolean ('false', 0) → refused. Unit-pinned for all four classes. ✅
  • (c) Client-side ?email= refused before the session is consulted. The 400 guard at line 175 precedes the verification predicate and the userEmail derivation, in the vendor's order; the session is fetched first (as in the vendor) but is not consulted until after the 400. ✅ Pinned by the "SCOPE PIN: ?email=" test (400 + "cannot be passed for client side").
  • (d) No session, no email → vendor 400 unchanged. Line 190. ✅ Pinned.
  • Can an unverified session read rows not addressed to its own email? No. userEmail on a client request can only be session.user.email (the ?email= path is dead for any ctx.request), and the adapter query is where email = userEmail. "List by organization" and "fetch by id" are not reachable from this route. The suite pins C's invitation absent from B's inbox and every(row.email === 'b@example.com') — the audience-gate seed row included. ✅
  • Same options object. organization.mjs line 81: const opts = options || {} and line 387: listUserInvitations: listUserInvitations(opts) — the vendor hands the caller's raw object through, so auth-manager.ts passing the one organizationOptions (satisfies OrganizationOptions) to both organization(...) and applyDeclaredInvitationVerificationToListing(...) is genuinely the same object the vendor's own getOrgAdapter reads. ✅
  • Error envelope. Refusal is APIError.from('FORBIDDEN', plugin.$ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION ?? local), pinned equal to the vendor's $ERROR_CODES entry. No new code minted. ✅

Not re-measured on the real pipeline in this seat: the review checkout has no node_modules installed, so the suite was not re-run here. The measurement I cite is CI's Test Core (1/6..6/6) on head c62690f75, all green (§8), plus the line-by-line source comparison above.

3. Vendor pin

  • packages/plugins/plugin-auth/package.json line 40: "better-auth": "1.7.2" (exact, plus @better-auth/core|oauth-provider|scim|sso at 1.7.2). pnpm-lock.yaml: better-auth@1.7.2 (integrity sha512-gKapKB…), override better-auth@<2.0.0: 1.7.2. ✅
  • Installed 1.7.2 crud-invites.mjs: shouldRequireVerifiedEmailForInvitationIdAction is defined at line 30 (if (organizationOptions.requireEmailVerificationOnInvitation !== void 0) return organizationOptions.requireEmailVerificationOnInvitation; return !hasBuiltInOpaqueInvitationIdGeneration(...)) and called at lines 270, 382, 505 — exactly three sibling sites. listUserInvitations carries if (session && !session.user.emailVerified) throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION); unconditionally. ✅ The PR's premise holds on the pinned vendor.
  • The suite's vendor pin resolves better-auth via createRequire from the package root, asserts exactly 3 shouldRequireVerifiedEmailForInvitationIdAction({ calls before the listUserInvitations declaration and the unconditional-refusal line verbatim inside it. An upstream fix that makes the listing read the option turns this red (the regex no longer matches / the not.toContain fails). ✅

4. Precedent parity

  • admin-impersonate-endpoint.ts applyPlatformAdminImpersonation (line 166): same guard if (!vendor || vendor.path !== PATH || !vendor.options) return false;, same plugin.endpoints.<key> = createAuthEndpoint(PATH, vendor.options, handler), same return true. The new module is a faithful copy of that shape. ✅
  • Log level: precedent uses console.error (auth-manager.ts ~3277); this PR uses console.warn. AGENTS.md → "Degradation log levels — warn vs error" decides with one question: "After the degradation, does the system still look 'normal' from the outside, while something it claims is persisted has not actually landed? Yes → error. No → warn/info is right." and "Functional degradation → warn / info. … The system is visibly smaller than it should be, and the next person to use the missing thing finds out." A vendor-drift fallback to the vendor's own 403 is visible to the caller and persists nothing falsely, so warn is the level the rule prescribes; the precedent's error is the outlier. Defensible. (The PR notes-but-does-not-file the precedent's level; correct — not this card's.)

5. Files in the diff and governed paths

Six files (git diff --stat 6ba0db4e0b..c62690f75: 815+/2−):

  1. .changeset/list-user-invitations-declared-verification.md (added)
  2. packages/plugins/plugin-auth/src/auth-manager.ts (+50/−2)
  3. packages/plugins/plugin-auth/src/list-user-invitations-verification.ts (added, 206)
  4. packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts (added, 515)
  5. packages/runtime/src/dispatcher-error-vocabulary.ts (+18)
  6. scripts/engine-double-contract.pinned.json (+15)

Governed paths touched: no — none under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** (grep exit 1). Governed Surface Queue Guard is green.

The new foreign-vocabulary row for EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION matches the four impersonate rows' grammar exactly: shape: 'objlit', door: 'none', verdict: 'foreign-vocabulary', a why naming the vendor version, the vendor error-codes file, the $ERROR_CODES read, the APIError.from(...) raise site, and the test pin that keeps the local restatement from drifting — the same structure as the USER_NOT_FOUND row (lines 509–524), which is the closest precedent (a local restatement pinned to the vendor). ✅ The new module is not re-exported from src/index.ts (grep exit 1) and the package exports map is unchanged.

6. Changeset

@objectstack/plugin-auth: patch; @objectstack/runtime not bumped. The batch #35 ruling, .github/workflows/pr-automation.yml "WHICH LEVEL", verbatim:

A purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value) takes at least minor. The commit type may raise a bump but never lower it below what the act requires; a fix( that widens an index is therefore minor, and a fix( that changes no public surface stays patch. … Ruled by the maintainer on 2026-09-04 (decision batch #35) on #15294

and scripts/check-changeset-no-major.mjs lines 765–774: "A declaration of ① plus a patch in ② is a self-contradiction inside one PR … This block mechanizes that sentence for exactly the PRs where the widening is already DECLARED, and for no others."

Reading: the ruling's "public surface" is the export/authoring surface (new symbol on an index, new accepted key or value). This PR adds no export, no key, no value, no error code — the wire response is the vendor's unchanged array. What changed is the runtime acceptance of an existing input class on an existing route, i.e. the "fix( that changes no public surface" clause → patch. Clause-② yes is the review-carrier decision (a conformance-class change is judgement territory), which is independent of the bump discriminator; the mechanised guard fires only on a declared widening, and none is declared. patch is correct. ✅ Runtime not bumped: verified precedent 2a3decc52 (plugin-schema-ui-required-keys) added rows to the same vocabulary file with spec/core minor and no runtime bump. ✅ The changeset body states before (403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION for the listing while the three siblings answered 200) and after (200, own-email pending rows, ?email= and no-session 400s unchanged, true/undeclared still refused). ✅

7. Tests (list-user-invitations-verification.test.ts, 16 tests)

  • Unverified throughout: signUp asserts Boolean(user.email_verified) === false for every fixture user (owner, B); the PREMISE test re-asserts B unverified after accept/reject/get; the PARITY test flips email_verified = true only after capturing the unverified answer. ✅
  • Own-email scope pin: "SCOPE PIN … another user's invitation is never visible" — cPartner.id absent, no row with email === 'c@example.com', every(email === 'b@example.com'). ✅
  • Parity pin: expect(unverifiedBody).toEqual(verifiedBody) plus exact issued-id set. ✅
  • Endpoint-shape identity pins: rebuilt.options.method/query/metadata toBe the vendor's; every vendor use entry contained; Object.keys(plugin.endpoints) unchanged before/after; rebuilt !== vendor; path equal. ✅
  • Vendor-source pin: §3. ✅
  • Would reverting the predicate redden the suite? Yes. With listingRequiresVerifiedEmail returning true for false, at minimum "the reported bug" (expects 200), the own-email SCOPE PIN (expects 200 + rows), "only PENDING rows" (expects 200), PARITY (expects 200 unverified) and "declared false → not required" go red — five, matching the PR's stated ablation. The guards (400/400), shape pins and vendor pin stay green by construction, which is the right partition.

8. CI on head c62690f75 (37 check runs)

29 success · 1 failure · 7 skipped · 0 in progress.

  • Red: Lint & Repo Gates — fails in pnpm check:route-envelope (see F1). All other required-looking checks are green: Test Core (aggregate + 6 shards), Build Core, TypeScript Type Check (+ workspace/source gates/consumer gates/debt ledger), Dogfood Regression Gate (aggregate + 3 shards), Dogfood Verify CLI, Temporal Conformance, Check Changeset ×2, Check PR Size, Governed Surface Queue Guard, both single-writer guards, Part-of PR must not also close its card, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter.
  • Skipped (path/opt-in filters): Packed-tarball smoke (opt-in) ×2, Console Pin Gate, Build Docs, and one each of Auto Label / Check PR Size on a second trigger.

Findings

F1 — CI red on head: check:route-envelope refuses the new module, and the fix is ⛔ MAINTAINER-ONLY. The job log: packages/plugins/plugin-auth/src/list-user-invitations-verification.ts — NOT DECLARED. This file writes Hono responses (c.json(…)), so it is a plugin-route module — add it to PLUGIN_ROUTE_MODULES in scripts/check-route-envelope.mjs. The body it builds (ctx.json(pendingInvitations), line 201) is better-auth's bare array — the vendor's published OpenAPI schema, read by authClient.organization.listUserInvitations and the SDK's organizations.invitations.listMine(). That is the vendorWire class, and the checker's header is explicit: "adding or widening an entry in this state is ⛔ MAINTAINER-ONLY (#8435), because it amends a ruling rather than applies one" and "the honest paths are the vendorWire state (⛔ MAINTAINER-ONLY — stop and escalate, never self-serve) or a byte-clean stop-and-report. The hoist is neither." The impersonate precedent has exactly such an entry (check-route-envelope.mjs lines 853–861, unenveloped: 1, vendorWire, note with vendor:/reader:/partner:). This also contradicts the PR body's "66 run / 0 exit 1" gate-union claim — check:route-envelope was evidently not in the derived union or its result was not reported. Expectation for this PR: do not self-add a vendorWire entry, do not add a ratchet (exclusive with vendorWire; the conversion can never honestly happen), do not hoist the literal. A maintainer adds the PLUGIN_ROUTE_MODULES entry for list-user-invitations-verification.ts (unenveloped: 1; vendor: better-auth (1.7.2); reader: authClient.organization.listUserInvitations / ObjectStackClient.organizations.invitations.listMine; partner: /organization/list-invitations and the three id-addressed invite routes, vendor-side) — or rules otherwise. Until then the PR stays draft and the head stays red.

F2 — The "ruled shape (a)" is a dispatch-seat ruling, not a maintainer's. No ## Ruling recorded comment exists on #16569 (four comments: triage, claim, os-dev-report, review-absent note); the seat's own claim comment calls the security-boundary question "维护者地板". The PR body's phrasing "the ruled shape (a)" is accurate for a seat ruling but should not be read as maintainer sign-off. Expectation for this PR: none in the diff; the maintainer merging it is the one recording the ruling. Combined with F1 and the needs:contract-review carrier on both issue and PR, this is a maintainer-only merge.

F3 — undeclared → refuse diverges from the siblings' default derivation. The vendor's three siblings derive an undeclared option from !hasBuiltInOpaqueInvitationIdGeneration(...); the rebuilt listing reads undeclared as "refuse" (the vendor list-route posture). For this repo, which declares false, it is moot; for a consumer who constructs the plugin without declaring, the listing keeps the vendor's current behaviour — no regression. Documented in the module header as deliberate. Expectation: none.

F4 — Suite not re-measured in this seat. This isolated checkout has no node_modules; the real-pipeline measurement I cite is CI Test Core 6/6 green on c62690f75 plus the source comparison in §2. Expectation: none; recorded so the reader knows which instrument produced the reading.

F5 — Three existing suites now emit the vendor-drift warn on every construction (auth-manager.test.ts, auth-email-locale.test.ts, verification-email-failure-propagation.test.ts mock an endpoints-less organization plugin; the PR notes this). Their console.warn spies silence it, so nothing goes red, but the warn is fired on a fixture, not a drift. Expectation: none for this PR; a follow-up could have the doubles carry a minimal endpoints record or the rebuild skip a plugin whose endpoints is absent-by-mock.

F6 — createAuthEndpoint appends better-call's base middleware a second time to use (2 → 3). Same shape as the impersonate precedent, no-op middleware, and the shape pin asserts every vendor middleware is still present. Expectation: none.


Generated by Claude Code

os-trump commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI red diagnosed — check:route-envelope, repair dispatched

Lint & Repo Gates (job 101916072838) is the only red on c62690f7; the other 36 checks are green or skipped. It is not a flake and not a base-branch failure.

✗ Route-envelope conformance (#3843)
  packages/plugins/plugin-auth/src/list-user-invitations-verification.ts
    NOT DECLARED. This file writes Hono responses (`c.json(…)`), so it is a
    plugin-route module — add it to PLUGIN_ROUTE_MODULES in
    scripts/check-route-envelope.mjs.

The new module writes a Hono response, and this gate treats a discovered-but-undeclared file as an ERROR, never a default.

Which declaration applies — measured, and it decides who may fix it

The module builds exactly one body, return ctx.json(pendingInvitations); (:201); the three refusals are throw APIError.*, which this surface does not count. The scanner guards its counters with if (arg && ts.isObjectLiteralExpression(arg)), above its own comment: "A relayed body (an identifier, a call, a member access) is not one this repo built." pendingInvitations is an identifier — the rows are better-auth's own, relayed from getOrgAdapter(…).listUserInvitations(…) and post-filtered, never shaped here.

So the expected reading is all-zero counters, and the honest declaration is the author-available {} — the diagnostic's first path, no ruling required. The sibling table already carries that exact shape for the same reason (security/inbound-rate-limit.ts: {}, whose comment records that the counters cannot see inside a call).

Stated as a provisional PM reading, to be re-measured by the repair — not asserted as fact.

The two paths that are not available here, recorded so the next reader of this class finds the prohibition rather than rediscovering the evasion: vendorWire and exempt are both ⛔ MAINTAINER-ONLY (#8435) — the #10554 ruling authorised exactly one vendorWire entry (admin-impersonate-endpoint.ts) and a second amends a ruling rather than applies one; and the const hoist is the gate's named forbidden move, not a third path. The repair is instructed that if any counter comes back nonzero it must stop and escalate to the maintainer rather than self-serve a vendorWire entry — a stop-and-report is an accepted outcome of that dispatch. Enveloping the body is also off the table: it would contradict the vendor OpenAPI metadata this endpoint passes through and break organizations.invitations.listMine.

Why the delivery's gate union missed it — not the author's error

The PR's 66 derived / 66 run / 0 exit 1 was honest. scripts/pm/dispatch-gates.mjs places check:route-envelope in its "Silent (source names paths, none of which cover yours — the weakest verdict)" bucket, because that gate's derived population is the list of already-declared route modules. A new undeclared route module matches none of them, so the family is never derived for the very card that introduces one — the gate that exists to catch undeclared route modules is undiscoverable-by-path for exactly that case. Filed separately as a tooling gap; not in scope for this PR.

PR stays in draft, and needs:contract-review is untouched.


Generated by Claude Code

…n PLUGIN_ROUTE_MODULES

The module added for the declared-verification fix writes a Hono response, so
the route-envelope walk discovers it, and a discovered file absent from the
table is an ERROR rather than a default.

Measured with the gate's own `scanHonoRouteSource`: one body, all six asserted
counters zero. The single write is `return ctx.json(pendingInvitations)`, whose
argument is an identifier -- the deliberate relayed-body blindness -- and the
three refusals are `throw APIError.*`, which this surface does not count. So
`{}` is the honest declaration: nothing this file builds departs from the
envelope. No ruled state applies and nothing was hoisted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37

os-trump commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI repair — the route-envelope declaration for this card's new route module

Follow-up commit f8685bbde on this branch. Nothing about the fix itself changed: the module's response shape, the predicate, the tests and the changeset are all untouched. This commit adds one table entry to a repo gate script and nothing else.

Lint & Repo Gates (run 34179688171, job 101916072838) carried one red — the other 36 checks were green:

✗ Route-envelope conformance (#3843)
  packages/plugins/plugin-auth/src/list-user-invitations-verification.ts
    NOT DECLARED. This file writes Hono responses (`c.json(…)`), so it is a
    plugin-route module — add it to PLUGIN_ROUTE_MODULES in
    scripts/check-route-envelope.mjs.

The module this card added writes a Hono response, so discover() sweeps it in, and a discovered file absent from the table is an ERROR rather than a default. The declaration is the fix.

Counters — measured through the gate's own scanner, not inferred

Driven through scanHonoRouteSource (the exact function audit() calls) on the file at this branch's head:

counter measured
bodies 1
reads 0
unenveloped 0
errorWithoutMessage 0
errorCodeNotString 0
strayKeys 0
stringError 0
siblingCode 0

All six asserted counters are zero, and every sites list came back empty. The one countable write is return ctx.json(pendingInvitations); at line 201 — its argument is an identifier, so it lands on the deliberate relayed-body blindness (if (arg && ts.isObjectLiteralExpression(arg)), the guard under the header's "A relayed body … is not one this repo built"). The three refusals are throw APIError.*, which this surface does not count at all.

The declaration, and why it is that one

{} in PLUGIN_ROUTE_MODULES — the author-available path the diagnostic names first: "If every body it BUILDS is the declared envelope, declare {}." pinned is 0, so no ratchet, exempt or vendorWire reason is required or permitted, and no maintainer ruling is amended. The comment above the entry follows the inbound-rate-limit.ts precedent on surface 4: what the file builds, why the count is what it is, and that the rows are better-auth's own — produced by the vendor's exported getOrgAdapter(ctx.context, options).listUserInvitations(email) and narrowed by the vendor's own status === 'pending' post-filter, never shaped here.

It also records the contrast with its twin: admin-impersonate-endpoint.ts takes the same in-place-rebuild door in this same package and needed the vendorWire ruling, because reimplementing that handler turned a relay into a built literal (ctx.json({ session, user })) and made a vendor-owned shape visible to the counters. Here the rebuild never re-shapes the body, so nothing became visible and no ruled state applies.

Nothing was hoisted. There is no object literal in this module to hoist, and the named forbidden move was not available even in principle.

Gate verdict

pnpm check:route-envelope (self-test then production) → exit 0. Production verdict line for this surface:

✓ Plugin-mounted Hono routes — 13 module(s) audited, 169 hand-built body/bodies (count reported, NOT pinned): 9 conformant, 0 ratcheted, 3 exempt, 1 vendor-wire

(12 modules / 8 conformant before this commit.)

Ablation — a green that can be made red

From the committed state, deleting only the new declaration line, under a trap ... EXIT INT TERM with absolute paths:

leg on-disk proof gate
baseline blob 9330412e… equal to HEAD:scripts/check-route-envelope.mjs; grep count 1 exit 0
mutated blob 63c4a6c3… differs from the HEAD blob; grep count 1 → 0 exit 1, naming exactly packages/plugins/plugin-auth/src/list-user-invitations-verification.tsNOT DECLARED
restored (git checkout HEAD -- ABSOLUTE_PATH) blob 9330412e… equal to the HEAD blob; grep count 0 → 1; git diff HEAD empty exit 0

Empty-hash-reads-as-failure and no-op-mutation guards were both armed and neither fired.

Gate union, re-derived after the commit

The first derivation from this branch's tree printed a STALE TREE warning (53 commits behind origin/main, 23 files it derives from changed across that range), which invalidates the answer — so it was re-derived from a fresh detached worktree at origin/main (7f96e1417), passing this card's seven paths. No staleness warning; the two unions turned out identical at 78 commands.

78 derived / 74 run and measured green / 4 NOT MEASURED / 0 UNRUN / 0 exit 1. The tool's own --ran reconcile from that same fresh tree exits 0:

✓ dispatch-gates --ran: 78 derived famil(ies) accounted for — 74 run, 4 NOT-MEASURED.

The four are check:dts-closure, check:dual-build-cjs-loads, check:sourcemap-no-sources-content and check:type-check-debt — each exited 3, their own PREREQUISITE NOT MET code, distinct from a finding's 1: they read the built dist/ closure, which is not built in this worktree. All four are derived by packages/**, which this commit does not touch, so it cannot move them; CI's Lint & Repo Gates runs them after a full build.

Worth noting for the record: pnpm check:pm-dispatch-gates cap-kills at the container's foreground ceiling, so it was run detached exactly as its own header instructs, and read to completion rather than written off as NOT MEASURED.

pnpm check:ratchet-remedy-authority — the family that guards this very table's remedy/authority grammar — is green.

eslint, narrowed and proven

pnpm exec eslint --no-inline-config --format json scripts/check-route-envelope.mjs1 file, 0 errors, 0 warnings, exit 0. The file count is read from eslint's own JSON output, so this is not a zero-file false green. Invariance, quoted from eslint.config.mjs's own comment: "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file" — so this diff cannot move the verdict on any untouched file. Repo-wide pnpm lint remains CI's run.

Changeset

None added, deliberately. This commit's only file is scripts/check-route-envelope.mjs, a repo gate script that no package publishes — it changes nothing a consumer installs, so there is no package to bump. The PR's user-visible change is already covered by .changeset/list-user-invitations-declared-verification.md (@objectstack/plugin-auth: patch), so the PR is not changeset-free and skip-changeset does not apply either.

验收备注 — addition

  • noted, not filed: this red was invisible to the card's own gate derivation, and the reason is structural rather than carelessness. dispatch-gates.mjs scores check:route-envelope into its silent bucket for a card that introduces a brand-new route module, because that gate's derived population is the list of already-declared route modules — so a file not yet in the table matches nothing. Measured from the other side here: once the declaration lands, the family flips to matched (via packages/plugins/plugin-auth/src/list-user-invitations-verification.ts ⇢ gate source '…verification.ts'). The PM is filing this separately; it is deliberately not touched in this PR.

Generated by Claude Code

os-trump commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Pointer for re-review — the owed vendorWire entry is deliberately absent, and why

The contract-review handoff (#16569, comment 5582291725) lists one item owed before re-review: a vendorWire entry with unenveloped: 1 for list-user-invitations-verification.ts, authorised by director ruling 5579654057.

It is not in f8685bbde, and it cannot be — the count it names is not this file's measurement. Full evidence on the card: #16569 (comment)

The short version, all measured through the gate's own scanHonoRouteSource:

  • This module's unenveloped is 0, not 1. It builds one body, return ctx.json(pendingInvitations), whose argument is an identifier — invisible to every counter on this surface by design, since they all read the object literal at the call site.
  • vendorWire + unenveloped: 1exit 1: "found 0, declared 1 — 1 fewer than pinned … or drop the entry entirely."
  • vendorWire + unenveloped: 0exit 1: "declares vendorWire but pins no non-conforming body … A module that departs from the envelope in no way these counters can see is conformant — declare {}."

The gate refuses the ruled state from both sides and names {} itself. The premise most likely slipped from the impersonate precedent, which earns unenveloped: 1 because reimplementing that handler turned a relay into a built literal (ctx.json({ session, user })); this rebuild never re-shapes the body, so nothing became visible to the counters.

The ruling's substance is intact and unchanged by this: the body is better-auth's wire format, the repo does not own the shape, and enveloping it would contradict the endpoint's vendor OpenAPI metadata and break organizations.invitations.listMine. Nothing here envelopes it. {} is the declaration for a relayed vendor body; vendorWire is for one that became a built literal.

Whether the ruling is amended to record that is the director's call, not this seat's. PR stays draft; pnpm check:route-envelope is green at f8685bbde.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16730 @ f8685bbde — re-review

Verdict: PASS — the {} declaration is honest by the checker's own definitions, and I measured it here with the checker's own exported scanner rather than reading the seat's table. F1 is discharged — and my previous review's remedy for it (vendorWire at unenveloped: 1) was wrong on the count and wrong on the class: it assumed the impersonate precedent's number without measuring this file. The corrected reading is below, with the checker's text that settles it. Nothing ⛔ MAINTAINER-ONLY was touched. What remains is not on the PR: ruling 5579654057 item (2) and the handoff's one owed item are unfulfillable as written and need the director seat to amend them (F7), plus the standing F2 note that every ruling on this card is a seat's, not the maintainer's.

Re-reviewed from refs/pull/16730/head = f8685bbde9a9d5a8f5225d243d4bb8157e2c4e22; prior review 5578957827 on c62690f75.

Delta since c62690f75

One commit, f8685bbde (chore(gates): declare the list-user-invitations endpoint conformant in PLUGIN_ROUTE_MODULES).

file change
scripts/check-route-envelope.mjs +24 / −0: one PLUGIN_ROUTE_MODULES entry, 'packages/plugins/plugin-auth/src/list-user-invitations-verification.ts': {}, with a 22-line comment (measured counters, why the write is an identifier, the explicit contrast with admin-impersonate-endpoint.ts)

Nothing else is touched (git diff --stat c62690f75..f8685bbde names that one file). The module, the predicate, the tests, auth-manager.ts, the vocabulary row, the engine-double ledger and the changeset are byte-identical to the reviewed head. No ratchet, no exempt, no vendorWire, no other table entry moved, no hoist.

The checker-definition verdict on {}

The question: does {} (all six counters zero) assert "every response this module writes is the ObjectStack envelope"? If so it is false — the only 2xx write, return ctx.json(pendingInvitations) (line 201), is better-auth's bare invitation array. By the checker's own text, it does not assert that.

What the surface-3 header says the counters govern (check-route-envelope.mjs, "## Relayed bodies are not counted, on purpose"):

Every counter reads an OBJECT LITERAL. A c.json(upstreamBody, status) that relays a control plane's own answer is invisible to all six, which is the correct answer rather than a gap: this gate governs the bodies this repo BUILDS, not the bytes it passes through — the same reasoning that makes a dispatcher domain's passthrough "kind 2" rather than drift.

What vendorWire is for — and it is narrower than "this module writes the vendor's shape on purpose":

A body this repo BUILDS whose shape is a VENDOR's wire format — required by that vendor's own client library — is outside BaseResponseSchema because the vendor owns the shape. Surface 2 already names this class kind 3 ("a foreign wire format a client library requires") and treats RELAYED instances as by-design invisible to the counters; vendorWire is kind 3's counterpart for the case where such a body becomes VISIBLE because the handler was reimplemented in-repo, turning a relay into a built literal.

The hoist prohibition draws the same line, and says which side a relay is on:

For a body this repo only relays, that invisibility is the design (see above). For a body this repo BUILDS it is the exact state this gate exists to prevent: the repo still owns the shape, and the hoist's only effect is to hide it from the auditor.

And the audit function itself names the declaration for this exact case (the vendorWire && pinned === 0 branch):

declares vendorWire but pins no non-conforming body. A vendor-wire declaration over nothing is a waiver over whatever this file emits next: the counters would stop meaning anything here the moment a bare body appeared. A module that departs from the envelope in no way these counters can see is conformant — declare {}.

So the class split is relayed vs. built literal, not vendor-shape vs. envelope. Both are kind 3 (vendor wire); only the built literal gets the vendorWire state, because only a built literal is visible and needs a ruled count to stay green. Applied to this file:

  • The write is ctx.json(pendingInvitations) where pendingInvitations is (await getOrgAdapter(ctx.context, options).listUserInvitations(userEmail)).filter(inv => inv.status === 'pending') — the vendor adapter's own rows, narrowed by the vendor's own post-filter. No object literal exists anywhere in the handler; the .filter does not re-shape a row. There is nothing to hoist, so the named forbidden move (const BODY = { … }; return c.json(BODY)) is not available even in principle.
  • The three refusals (lines 181, 188, 194) are throw APIError.*, not .json writes; the scanner does not count them, by design — identical to the impersonate entry's own comment ("the four refusals are throw APIError.from(…), which this surface does not count").
  • The impersonate precedent (admin-impersonate-endpoint.ts, unenveloped: 1, vendorWire, note with vendor:/reader:/partner:) is the other half of kind 3: reimplementing that handler produced a built literal ctx.json({ session, user }) (its comment: "It is the ONLY body this file BUILDS that any counter here reads"). That is why it has a count of 1 to pin and why it needed a ruled state. This rebuild took the same door but never converted the relay into a literal.
  • Table precedents already read {} this way: inbound-rate-limit.ts: {} ("That the counters cannot see INSIDE buildApiError(…) … is the deliberate relayed-body blindness, so read this {} for what it is: nothing this file BUILDS departs from the envelope"); error-response.ts ("both sites write resolved.body, an IDENTIFIER, and the dialect counters only see object literals"); and — the closest — the auth-plugin.ts entry's own note: "The rest of this file (~46 bodies) is better-auth's own wire format, relayed rather than built, and stays invisible to these counters by design." Better-auth's wire, relayed, has been {}-class on this surface since [Decision] Pre-auth discovery/bootstrap payloads: inside BaseResponseSchema (coordinated objectui flip) or ruled exempt with reasons — today they are neither #9389; this file is one more instance.

Is it a scanner-blindness pass? The blindness is the declared design, and it was not manufactured: the control below shows a built literal at this site is caught. {} is also stricter than any vendorWire spelling would be — the counters stay live at zero, so the next literal at this boundary goes red, whereas a vendorWire at 0 is refused as "a waiver over whatever this file emits next".

Verdict on the question: (a) — {} is honest by the checker's own definitions. The one residual judgement call is that this body is computed (adapter call + filter) rather than a pure c.json(upstreamBody) passthrough; it resolves toward relay because the AST class the checker uses is "an identifier, a call, a member access", the shape ownership is the vendor's (adapter rows + the vendor's OpenAPI schema the endpoint passes through), and the filter mints no key.

Numbered verification

  1. Measured here, with the checker's own functions. I extracted check-route-envelope.mjs and its three helpers from the PR head plus the module into a scratch tree (global typescript symlinked in; no checkout, no install) and drove scanHonoRouteSource on the module: {"bodies":1,"reads":0,"unenveloped":0,"errorWithoutMessage":0,"errorCodeNotString":0,"strayKeys":0,"stringError":0,"siblingCode":0}, every sites list empty. Matches the commit message and the table comment exactly.
  2. All spellings through auditPluginRouteModule: {}GREEN. vendorWire + unenveloped: 1 + a conforming three-party note (the entry ruling 5579654057 / handoff 5582291725 asked for) → RED: "unenveloped: found 0, declared 1 — 1 fewer than pinned … or drop the entry entirely — nothing departs from the envelope here any more". vendorWire + unenveloped: 0 + note → RED: "declares vendorWire but pins no non-conforming body … declare {}". Undeclared → RED "NOT DECLARED". So the authorised entry cannot be written green in either count, independently confirming the seat's 5582971711.
  3. Control — the blindness is not a loophole at this site: mutating line 201 to ctx.json({ invitations: pendingInvitations }) measures unenveloped: 1 at :201 and makes {} RED; mutating to ctx.json({ success: true, data: pendingInvitations }) measures 0 and stays green. The gate sees exactly what its header says it sees.
  4. Table comment vs. code: the entry's comment claims match the source — one ctx.json at 201 with an identifier argument; refusals at 181/188/194 are throw APIError.fromStatus/APIError.from; the cited inbound-rate-limit.ts: {} precedent exists on surface 4 (line 996 on this head); the impersonate contrast is accurate to that entry's own text.
  5. Authority: {} is the author-available path the NOT DECLARED diagnostic names first ("If every body it BUILDS is the declared envelope, declare {}"). No ruled state was added or widened, so the ⛔ MAINTAINER-ONLY marker (check:engine-double-contract fires at CI time, not authoring time — four independent PRs tripped it on brand-new test files in one shift, and a pre-warning in the brief did not prevent it #8435) is not engaged by this commit. The vendorWire authorisation in 5579654057 was not used — correctly, since it cannot be.
  6. CI on f8685bbde — 37 check runs: 30 success · 0 failure · 0 in progress · 7 skipped. Lint & Repo Gates is green (job 102003068925, 09:08–09:33Z), so check:route-envelope passes on the head. Green: Test Core (aggregate + 6 shards), Build Core, TypeScript Type Check (+ workspace / source gates / consumer gates / debt ledger), Dogfood Regression Gate (aggregate + 3 shards), Dogfood Verify CLI, Temporal Conformance, Check Changeset ×2, Check PR Size, Governed Surface Queue Guard, both single-writer guards, Part-of PR must not also close its card, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter. Skipped (path / opt-in): Packed-tarball smoke ×2, Console Pin Gate, Build Docs, one Auto Label, one Check PR Size.
  7. PR state: draft; mergeable_state: clean (read unknown on my first fetch, clean on the second); labels documentation, size/l, tests, tooling, needs:contract-review (the carrier was re-hung as the handoff instructed); 5 commits, 7 files, +839/−2; 64 commits behind origin/main (merge-base 6ba0db4e0) — no conflict reported.
  8. Suite not re-run in this seat (no node_modules in the review checkout, as before); the commit does not touch any package, so the c62690f75 readings and CI Test Core 6/6 green on f8685bbde stand.

Findings

F1 — discharged, with a correction to my own prior reading. check:route-envelope is green on the head via {}. The previous review's F1 said the honest declaration was a vendorWire entry at unenveloped: 1; that inherited the impersonate precedent's count without measuring this file, and conflated the two halves of the checker's kind 3. The checker's own definitions (quoted above) put a relayed vendor body in the {} class and reserve vendorWire for a built literal. {} here is honest and is the stricter declaration. Nothing owed on the PR.

F2 — still open, unchanged in kind. Shape (a) and the vendorWire authorisation are rulings of the dispatch seat and the director seat (5579654057 cites a verbatim maintainer delegation, 「你应该自主处理完」 / 「自主继续呈报」). There is still no ## Ruling recorded comment written by the maintainer on #16569; the director seat's is titled that but is a seat ruling under delegation. Whether that delegation covers a security-boundary accept-set change is above this seat; recorded, not decided.

F3 — undeclared → refuse diverges from the siblings' default derivation. Unchanged; moot for this repo (declares false); expectation none.

F4 — suite not re-measured in this seat. Unchanged; CI is the instrument (§6, §8); expectation none.

F5 — three suites now emit the vendor-drift warn on every construction (mocked endpoints-less organization plugin). Unchanged; expectation none for this PR; follow-up candidate.

F6 — createAuthEndpoint appends the base middleware a second time (2 → 3). Unchanged, same as precedent; expectation none.

F7 — new: ruling 5579654057 item (2) and handoff 5582291725's owed item are unfulfillable as written, and need amending by the director seat, not the PR. The authorised entry (vendorWire, unenveloped: 1) is red in both counts (§2) — the gate's own closing sentence names {}. The ruling's substance (the body is better-auth's wire format, the repo does not own the shape, enveloping it would contradict the vendor OpenAPI metadata this endpoint passes through and break organizations.invitations.listMine) is intact and is exactly what the relayed-body blindness already encodes. Expectation for this PR: none. Expectation for the director seat: amend item (2) to record {} as the correct declaration for a relayed vendor body (the class boundary is relayed vs. built literal, per the checker header), so the card's record matches the tree before it lands. The seat's 5582971711 and the PM's 5583050675 already route this correctly.

Maintainer-only merge: no — by the gate table. This commit takes the author-available {} path; no ⛔ MAINTAINER-ONLY state (exempt / vendorWire) was added or widened, which was the whole of the previous review's F1 basis for "maintainer-only". The residual authority question is F2: every ruling on #16569 is a seat ruling (dispatch seat for shape (a); director seat, citing maintainer delegation, for ratification and the now-moot vendorWire item), not the maintainer's own. If that delegation stands, the director seat lands it per its own execution plan in 5579654057 after amending item (2) (F7); if it does not, this is a maintainer merge. That is the maintainer's / director's call, and this seat records it rather than making it.


Generated by Claude Code

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/l tests tooling

Projects

None yet

3 participants