fix(plugin-hono-server): the current-user faces assemble their ExecutionContext through the shared assembler - #15891
Conversation
…ionContext through the shared assembler `makeExecutionContextResolver` built the envelope for `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` as a hand-rolled object literal cast `as any` — sitting beside `assemble-execution-context.ts`, the module whose whole purpose is to make a partial envelope unrepresentable by CLOSING the field set with a type. It omitted six fields of that closed set: `principalKind`, `onBehalfOf`, `audience`, `accessToken`, `authGate` and `oauthScopes`. That is the #6071 drift class exactly — a field exists on `ExecutionContext`, one transport carries it, another silently does not. The resolver now calls `assembleExecutionContext`, the fail-closed default entry (#6216 Option A), and passes every per-face divergence explicitly instead of omitting it: no OAuth door, no localization (the endpoint reads that cascade itself since #15387), no session bearer, no auth gate. The declared return type narrows from `any` to `ExecutionContext | undefined`, so a field added to `ExecutionContext` from here on fails to compile until this face decides it. All three faces answer byte-identical bodies. `principalKind` becomes `'human'`, which its only reachable reader on these routes — the security plugin's `context?.principalKind === 'agent'` — already treated an absent value as. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…ecution-context-envelope
📓 Docs Drift Check1 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
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5cde623e85ab2811e788d57b3f81fc2704c88fc5 && git checkout 5cde623e85ab2811e788d57b3f81fc2704c88fc5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f50c394da55846da8d38f1e1efadbc24faa26ce2 8fa249a15becdc887f20dc10770528a275e163ac && git checkout -B drift-repro f50c394da55846da8d38f1e1efadbc24faa26ce2 && git merge --no-ff 8fa249a15becdc887f20dc10770528a275e163ac
node scripts/docs-audit/affected-docs.mjs --json f50c394da55846da8d38f1e1efadbc24faa26ce2 |
os-litant
left a comment
There was a problem hiding this comment.
Contract review (clause ②) · head f457224b9dd
Verdict: CHANGES REQUESTED — on exactly one item, the changeset declaration (section ② below). Every other leg was re-derived from the delivered diff and reads PASS; none of them needs a code change beyond the one line the changeset fix itself requires.
Posted as a COMMENT review: GitHub refuses APPROVE on a PR authored under this same account, and an agent seat never submits an approving review in any case. Governed-surface check: the three delivered files (.changeset/lucky-poems-invite.md, the new test, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts) touch no governed surface. Nothing here strips or clears a label.
Implemented-by: claude/issue-15747-execution-context-envelope (mode:subagent — the branch is the identity)
Reviewed-by: session_01D47qPfEWVPmhguWgBZCi5N
The two identities differ, so this is not a SELF-REVIEW under the C4 rule.
Tier fuse, read before judging (contract-review reference, 降档保险丝): the two sanctioned readings for this turn — get_session.last_served_model and the harness-stamped model field on this turn's own transcript records — agree with each other and do not equal CONTRACT_REVIEW_TIER (single source: scripts/pm/dispatch-gates.mjs). Under that reading this seat may not self-clear needs:contract-review. The verdict below is CHANGES REQUESTED — the conservative direction — and it rests on a ratified ADR rule plus a gate run, so no clearing depends on it. The PASS readings on the other legs are handed to the at-tier re-review of the patch round as measurements to re-verify, not as a clearing.
① Derived judgments — both clause-② limbs, from the DELIVERED diff
Mechanical / path limb — YES, upheld. The dev's characterisation of the published-shape change is accurate and complete:
makeExecutionContextResolveris published:export * from './current-user-endpoints'(src/index.ts:4);@objectstack/plugin-hono-server17.3.0 carries noprivate, andexports["."].typesis./dist/index.d.ts.- Shape change 1 — the type: the resolver the function returns narrows from
(c: any) => Promise<any>to(c: any) => Promise<ExecutionContext | undefined>. Note the exported function itself carries no return annotation at either rev; the annotation moved on the inner closureresolveCtx(:576), and the exported symbol's declaration is inferred from it. That detail decides the gate outcome in ② below. - Shape change 2 — the envelope:
+principalKind: 'human';−tabPermissionsonly whenresolveUserAuthzGrantsmerged none —packages/core/src/security/resolve-authz-context.ts:977assignsgrants.tabPermissionsonly when non-empty, andtabPermissionssits in the closed set (assemble-execution-context.ts:129), so a defined value is carried. No former key is lost and nothing new leaks in fromgrants:entryFields()reads exactly the ten authz fields the deleted literal copied (positions, permissions, systemPermissions, userId, tenantId, email, tabPermissions, posture, org_user_ids, accessible_org_ids) plusisSystem: false; every other closed-set field isundefinedon this face and dropped byemit()(:264-271). - Wire bodies: pinned goldens in the new suite; all six required checks green on this head.
- Is narrowing
any→ExecutionContext | undefinedbreaking for an external consumer? Yes — at compile time, and real rather than theoretical. The repo has already ruled on precisely this class: ADR-0087, addendum 2026-08-30 (#13080): "anyis assignable to everything and admits every property read, so a consumer's code really can stop compiling." Concretely here:const ctx = await resolve(c); ctx.userIdstops compiling understrictNullChecks(theundefinedarm), and any consumer that leaned onanyto read undeclared members or to assign the result where a different type is expected stops compiling. Runtime is unchanged on every path. Whether any such consumer exists is NOT MEASURED (below).
Non-mechanizable conformance limb — YES, upheld. The "yes under the doctrine rather than under the measurement" posture is correct, and the measurement behind it holds: the one reachable reader is const isAgent = context?.principalKind === 'agent' (security-plugin.ts:4696), false for undefined and for 'human' alike; the memo-key change from null to 'human' (permissionSetMemoKey, :4625-4634) cannot cross requests because permissionSetMemo is a WeakMap keyed on the context object; the 'agent' arm is absent from this face's code path (next section). No principal's verdict changes on any measured path.
The LATENT chain — the last link, verified
makeExecutionContextResolvernever callsresolveExecutionContext— holds. Function body at head (:563-684) read in full: session viaapi.getSession({ headers })(:585), grants viaresolveUserAuthzGrants(:603), assembly viaassembleExecutionContext(:662) withoauth: undefinedliterally (:673). Grep of the whole file forresolveExecutionContext|acceptOAuthAccessToken|verifyMcpAccessToken→ one hit, the comment at :639. Control on the same file and pattern form (discriminates on "does the grep reach this file and match", not on the OAuth symbols):resolveUserAuthzGrants→ 3 hits. The@objectstack/coreimport list isassembleExecutionContext, IDataEngine, resolveLocalizationContext, resolveUserAuthzGrants;resolveExecutionContextlives in@objectstack/runtimeand is not imported at all.- Assembler:
const agent = !anonymous && oauth?.clientId ? oauth : undefined(:293),principalKind: agent ? 'agent' : anonymous ? 'guest' : 'human'(:316);assembleExecutionContextanswersundefinedwithout auserId(:366), so'guest'is unreachable here too. acceptOAuthAccessTokenrepo-wide (non-test, non-dist): one production setter,packages/runtime/src/http-dispatcher.ts:565(gated on the/mcpregex); the remaining hits are the option declaration (resolve-execution-context.ts:67), its reader (:116) and four comments.oauthPrincipalis assigned only inside theif (opts.acceptOAuthAccessToken)block (:116-135) and is what reaches the assembler asoauth:(:221).- #15859 (still open, head
4538d6289d5on base59953d5— the state the dev measured against): its OAuth-gate site edits the regex on that one setter, which stays an option of a function this resolver does not call. Taken on the dev's measurement; state confirmed unchanged.
emit() drops undefined — no reachable consumer keys on PRESENCE
hasOwnPropertyinsecurity-plugin.ts: :1651(row, field)and :2592(r, 'owner_id')— data rows, as stated. :4618 is the comment explaining why the memo key deliberately does notJSON.stringify(context). No' in context,Object.keys(context,Object.hasOwn,Reflect.hasanywhere in the file.- The complete read set on the reachable path: the
ISecurityServicefacade at :1526 is a pure pass-through →resolvePermissionSetsForContext(:4652) → memo key (positions, permissions,principalKind ?? null,userId ? 1 : 0) → unmemoized body (:4685-4752:context?.positions ?? [],context?.permissions ?? [],context?.principalKind === 'agent',context?.userId) →dbLoaderForContext→callerOrganizationId(:906,organizationId ?? tenantId). All value-based. - Handlers in
current-user-endpoints.ts:execCtx.userId / tenantId / positions ?? [](:981-983),execCtx.systemPermissions ?? [](:1187),(execCtx as any).tabPermissions ?? {}(:1188). Grep forObject.keys(execCtx|JSON.stringify(execCtx|...execCtx|in execCtx|hasOwn→ 0; control on the same file (execCtx.reads) → hits.
⇒ Dropping tabPermissions when undefined is a property of the consumers, not a coincidence of the tested inputs.
② Semver level — the finding
Verdict: breaking, with the disposition not-required (type-surface-only …#makeExecutionContextResolver). The delivered minor changeset with no **BREAKING** token and no marker is the exact shape ADR-0087's addendum names as a counter-example.
- ADR-0087, addendum 2026-08-30 (#13080), "the sixth category": "A published TYPE-surface narrowing — a published SDK method whose declared return moves off
anyonto the contract it always answered — declares**BREAKING**truthfully." Then: "#8140, #11925, #12034 and #12104 are all … return-type narrowings, all shippedminor, all recording the no-entry disposition in prose, none carrying the token … a COUNTER-EXAMPLE, not a precedent" and "⛔ Dropping the token is no longer an available exit." - This diff is that class on all four predicates: (1) the package publishes; (2)
packages/spec/**untouched; (3) no*.zod.ts, nopackages/spec/src/contracts/**, no object definition moved; (4) erased at the merge base, concrete at HEAD — with the one wrinkle the gate itself reports below. - Gate output read, not guessed (
scripts/check-adr-0087-registration.mjs --base origin/main --head HEAD, in a throwaway scratch worktree atf457224b9dd; two local probe commits, never pushed; worktree removed afterwards;refs/pr/15891unchanged atf457224b9dd):- As delivered → exit 0:
✓ … this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen). The gate is silent because nothing was declared — that silence is the erosion the addendum describes, not a pass. - Probe A —
**BREAKING**token +<!-- adr-0087: not-required (type-surface-only packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#makeExecutionContextResolver) … -->, code untouched → exit 1, refused on predicate 4: "makeExecutionContextResolver, which carries NO return annotation is still UNANNOTATED. This category is for a surface that MOVED OFF an erased type." The gate is a source-text reading of the named symbol; the narrowing sits on the inner closure, so the exported symbol looks unmoved. - Probe B — probe A plus one line, an explicit annotation on the exported function:
export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext): (c: any) => Promise<ExecutionContext | undefined> {→ exit 0:[BREAKING] not-required (type-surface-only) -- verified: …#makeExecutionContextResolver (unannotated -> (c: any)).
- As delivered → exit 0:
- The bump level itself stays
minor:scripts/check-changeset-no-major.mjsrefuses amajorbump (a breaking change does not burn a major version in the launch window). The live in-repo instance to copy is.changeset/driver-memory-update-upsert-honest-types.md— "BREAKING for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names … shipped asminorunder the launch-window convention." - Why this category and not another:
no-migration-prescriptionis the answer only whentype-surface-onlyis unclaimable (the driver-memory instance touchedpackages/spec/**) and it is refused when the body carries a FROM/TO block;runtime-interface-onlyinherits that same refusal;registered/already-registeredhave no entry to name — nothing exists forobjectstack migrate metato rewrite, and a fabricated id would be false data in the one ledger the mechanism keeps true.
Required for the patch round (all in the two delivered files):
.changeset/lucky-poems-invite.md: keepminor; add the**BREAKING** for TypeScript consumers …sentence stating FROM → TO ((c: any) => Promise<any>→(c: any) => Promise<ExecutionContext | undefined>) and the one-line consumer fix (narrow theundefinedarm; type any member previously read throughany).- Add the marker:
<!-- adr-0087: not-required (type-surface-only packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#makeExecutionContextResolver) <why> -->. - Add the explicit return annotation on
makeExecutionContextResolver(one line;ExecutionContextis already imported as a type in the file). Without it the gate refuses the claim (probe A); with it the gate verifies it (probe B). The annotation makes the published declaration explicit rather than inferred and changes nothing else, so it does not re-open the mechanical limb. - Re-run
pnpm check:adr-0087-registrationand the gate union on the new head; the declared-breaking changeset also makes the CI step Require an ADR-0087 disposition on a declared-breaking changeset load-bearing.
③ Boundary flags
- The one flag the dev raised — the changeset level, explicitly left to this review — is answered in ②.
open_questions: [],out_of_scope_findings: []— nothing else to dispose.
NOT MEASURED (reported as such, never as a pass)
- External consumers of
makeExecutionContextResolver, including the serverless host composition named in cloud#924: the cloud repository is not attached to this session, and GitHub code search's must-hit control for that repository returned 0 withincomplete_results: true, so the org-wide search result (1 hit, this file) is not a reading for cloud. The objectui sibling checkout greps 0 for the symbol, and that one is a reading: two comment hits oncurrent-user-endpoints.tsin the same sweep prove the grep reaches it; objectui carries no dependency on this package. - The emitted
dist/index.d.tswas not built here; the before/after declaration shapes above follow from how declarations are emitted for an unannotated export, not from a built artifact.
Re-derived vs taken on trust
- Re-derived at the PR head: every code fact cited above, the gate runs, the in-repo consumer count (zero outside the module; control
registerCurrentUserEndpoints→hono-plugin.tsplus six suites), and the C4 identity pair. - Taken on trust: both ablation limbs and their on-disk mutation proofs, the 55/55 gate union from the Reconciliation line, the source-vs-dist proof, the before-state 11-key envelope (consistent with the deleted literal by inspection), and the #15859 interaction measurement (its state confirmed unchanged).
Generated by Claude Code
Seat correction: the tier fuse in the review above misfired — the review WAS at
|
| transcript | assistant-record stamps |
|---|---|
this review (agent-a65070dfef24a2de3) |
claude-fable-5-1 × 76, nothing else |
| the four sibling contract reviews | claude-fable-5-1 × 98 / 138 / 90 / 113, nothing else |
control — an os-dev agent dispatched without the tier override |
claude-opus-5 × 253 |
| the parent session the reviewer actually read | claude-opus-5 × 2401 |
CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:8659). The control discriminates, so this is a reading rather than a grep that matches everything.
⇒ The verdict stands at tier. CHANGES REQUESTED is a tier verdict, and needs:contract-review correctly remains on both carriers (this PR and card #15747) until the patch round clears it.
⭐ Worth keeping despite the misfire: the reviewer was right to refuse to clear on a self-description, and right to post the conservative verdict anyway rather than stall. The protocol's rule — that a job's own account of its tier is not a reading — is exactly what caught four sibling reviews' self-reports and forced this seat to measure them independently. The rule worked; only the file path was wrong.
The finding itself is correct and is not softened by any of the above
⭐ minor without a **BREAKING** declaration is not an available exit here, and the reviewer did not infer that — it read the governing text: the ADR-0087 addendum of 2026-08-30 (#13080) names minor-without-**BREAKING** return-type narrowings as "a COUNTER-EXAMPLE, not a precedent" and says dropping the token "is no longer an available exit."
Narrowing (c: any) => Promise<any> → Promise<ExecutionContext | undefined> is breaking for an external TypeScript consumer at compile time — the undefined arm under strictNullChecks, and undeclared member reads. Runtime is unchanged, which is why the bump stays minor; check-changeset-no-major.mjs forbids major outright.
⭐ And the fix was gate-probed before being asked for, which is what makes this actionable rather than a suggestion:
| probe | result |
|---|---|
| as delivered | exit 0, "adds no declared-breaking changeset" — silence, not a pass |
| A — BREAKING token + marker, code untouched | exit 1: predicate 4 refuses, the exported symbol "carries NO return annotation … still UNANNOTATED" |
| B — A plus a one-line explicit return annotation | exit 0, verified |
⇒ The patch round needs three things, not two: the BREAKING sentence with FROM→TO and the consumer fix; the marker not-required (type-surface-only packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#makeExecutionContextResolver); and the one-line explicit return annotation on the exported function — without which the marker itself is refused. Shape to copy: .changeset/driver-memory-update-upsert-honest-types.md.
Everything else in the review verified PASS: both clause-② limbs upheld; the LATENT chain's last link confirmed (the resolver body at :563-684 never calls resolveExecutionContext, the only file match being a comment at :639, with resolveUserAuthzGrants → 3 hits as the control); no presence-keying anywhere in the reachable read set, so emit() dropping undefined-valued keys is safe here rather than coincidentally safe; C4 pair correct (Implemented-by: branch ≠ Reviewed-by: session — not SELF-REVIEW); no governed surface touched; all six required CI checks green.
NOT MEASURED, declared rather than passed: external consumers including cloud#924 — that repo is not attached, and GitHub code search's must-hit control returned 0 with incomplete_results: true, so the org-wide single hit is not a reading. The objectui sibling checkout is 0 consumers and that one is a reading (its control hit).
The implementing agent is being sent back for the patch round.
Generated by Claude Code
…declare the type-surface narrowing as BREAKING Contract review graded the return-type narrowing breaking, and it is: `any` is assignable to everything and admits every property read, so an external TypeScript consumer's code really can stop compiling on the `undefined` arm under `strictNullChecks` and on reads of members `ExecutionContext` does not declare. The bump stays `minor` — `check-changeset-no-major` refuses `major` during the launch window, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level. The ADR-0087 addendum of 2026-08-30 (#13080) names `minor`-without-`BREAKING` return-type narrowings as a counter-example rather than a precedent, and closes dropping the token as an exit. The changeset now carries the banner with the FROM/TO shape and the consumer's fix, plus the `type-surface-only` disposition. The one code change is what that disposition's fourth predicate requires: the narrowing lived only on the inner closure, leaving the EXPORTED symbol unannotated at both revisions, so the marker itself was refused. The exported `makeExecutionContextResolver` now declares `(c: any) => Promise<ExecutionContext | undefined>`. No behaviour change: all three current-user faces answer the same bodies, and their goldens are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…ecution-context-envelope
os-litant
left a comment
There was a problem hiding this comment.
Contract re-review (clause ②, scoped) · head 31431321cad
Verdict: CLEARED. The one item the prior review (#pullrequestreview-5121417846) blocked on — the changeset declaration — is resolved on this head, and the patch round changed nothing else. Every leg that review verified PASS is taken as settled and was not reopened. Clause ② may be stripped by the seat; nothing here strips or clears a label, and no merge, ready flip, enqueue or auto-merge is touched.
Posted as a COMMENT: GitHub refuses APPROVE on a PR authored under this same account, and an agent seat never submits an approving review in any case. Governed-surface check re-confirmed on this head: the three delivered files (.changeset/lucky-poems-invite.md, the new test, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts) touch no governed surface.
Tree freshness — confirmed before any gate was believed
origin/mainwas fetched at the start (b31ebfe), and the PR head as pushed is 8 commits behind it (its merge parentad0b3e7is the merge base). A freshness re-read after the first gate run showedorigin/mainhad moved again, tof9a3c32, so the gate was run a second time on a re-merged tree.- Both gate scripts are blob-identical at the PR head and at
origin/main(check-adr-0087-registration.mjs=8e9cbd0d…,check-changeset-no-major.mjs=76eeeddd…), so the instrument is not stale in the dimension under test. The one commit that moved main during this review touched neither script. - The final gate reading was taken in a scratch worktree on a local, never-pushed merge of the PR head with
origin/main@f9a3c32:git merge-base --is-ancestor origin/main HEAD→ 0 before and after the run, andgit diff --stat origin/main HEAD= exactly the PR's three files. The same gate was also run against the PR head as pushed; the verdict is identical.
The gate, re-run here (not inherited)
node scripts/check-adr-0087-registration.mjs --self-test → exit 0 (325 assertions)
node scripts/check-adr-0087-registration.mjs --base origin/main --head HEAD → exit 0
✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
.changeset/lucky-poems-invite.md [BREAKING] not-required (type-surface-only) -- verified: packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#makeExecutionContextResolver (unannotated -> (c: any))
node scripts/check-changeset-no-major.mjs --base origin/main --head HEAD → exit 0
✓ This diff introduces no `major` bump.
Exit codes captured before any pipe. Controls in the same worktree, each a local probe commit restored afterwards (final tree: 0 dirty, source blob 6512f7a6… = the PR head's):
| control | expected | measured |
|---|---|---|
token-absent: gate against the OLD head f457224b9dd |
silence, not a pass | exit 0, adds no declared-breaking changeset (1 non-breaking changeset(s) seen) — a different reading, so the instrument discriminates |
| probe R: exported return annotation stripped (file restored to old-head content; 1 old signature / 0 new, on disk) | refuse on predicate 4 | exit 1, [predicate 4: narrowed-from-erased] is false at HEAD: … is still UNANNOTATED |
probe M: marker line deleted, **BREAKING** kept (0 markers / 1 banner, on disk) |
refuse for silence | exit 1, declares a breaking change (BREAKING) but no adr-0087: disposition marker |
probe X: minor → major |
refuse | exit 1, ⛔ This PR introduces changeset(s) that declare a major bump |
① The BREAKING sentence — the FROM claim checked at both revisions, not accepted from prose
- Merge base
ad0b3e7:export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) {(:558, no return annotation); inner closureconst resolveCtx = async (c: any): Promise<any | undefined> =>(:571); envelope} as any;(:630);return resolveCtx;(:635). So the FROM text(ctx: CurrentUserEndpointsContext) => (c: any) => Promise<any | undefined>is literally what a declaration emit produces for that shape, and theas anycast is real. - "In practice
any" — measured, not argued: a standalone reduction of the three shapes (base /f457224b9dd/ this head) under TypeScript 6.0.2 (repo pins^6.0.3) with--declarationemits(c: any) => Promise<any | undefined>for the base shape, and a--strictconsumer of it compilesctx.userIdandctx.notDeclaredwith no narrowing and no error — the receiver behaves asany. The same consumer against thef457224b9ddand head shapes fails on exactly the two arms the banner names:TS18048 'ctx' is possibly 'undefined'andTS2339 Property 'notDeclared' does not exist on type 'ExecutionContext'. The banner's consumer fix is the compiler's own message. f457224b9ddalready inferred(c: any) => Promise<ExecutionContext | undefined>for the export; the annotation on this head makes that declaration explicit and changes nothing else, so the mechanical limb is not reopened.- "A consumer who only calls
registerCurrentUserEndpointssees no change" holds: the whole PR diff (-U0, merge base → head) touches exactly one exported declaration,makeExecutionContextResolver.registerCurrentUserEndpoints(options: RegisterCurrentUserEndpointsOptions): booleanis unchanged, and thewithRequestContexthelper that carriesReturnType<typeof makeExecutionContextResolver>is a local const inside it, not an export.
② The marker — category and vocabulary
- Present, well-formed, with its reason:
<!-- adr-0087: not-required (type-surface-only packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#makeExecutionContextResolver) … -->. type-surface-onlyis the right category. ADR-0087 addendum 2026-08-30 (#13080) defines it as "a published SDK method whose declared return moves offanyonto the contract it always answered", with four predicates the gate checks by name (published,no-spec-diff,no-metadata-surface-diff,narrowed-from-erased): the package publishes (17.3.0, noprivate),packages/spec/**is untouched, no shape surface moved, and predicate 4 verified asunannotated -> (c: any). The other categories stay closed for the reasons the prior review gave.- Judged from the gate's
CATEGORIESconst (scripts/check-adr-0087-registration.mjs:460-466):unpublished,already-registered,no-migration-prescription,runtime-interface-only,type-surface-only— fivenot-requiredcategories plus theregisteredform, the six-form set. The header docblock (:59-66) lists only fournot-requiredforms and omits this one — stale, #15915 — and was not used to judge the category. The gate's own vocabulary-drift check (#8299,CATEGORIES↔ ADR in both directions) runs on the realassertInputs()path, which refuses to report a verdict at all if the two sets drift; a verdict was reported, and the 325-assertion self-test passed.
③ The annotation — and nothing else
- Patch-round commit
7ba5f7c(parentf457224b9dd) changes two files: the changeset, andcurrent-user-endpoints.ts+3/−1 — the one-line signature becoming the three-line annotated one. That is the entire code delta. - The head
31431321cadis a merge of7ba5f7cwithorigin/main@ad0b3e7:git show --remerge-diffon it is empty (no evil-merge content), and its diff against its first parent underpackages/plugins/plugin-hono-server/and.changeset/is only the three changesets main gained. The new test file is byte-identical betweenf457224b9ddand this head.ExecutionContextwas already imported as a type (:52), so the annotation adds no import.
Bump level — minor stays correct, and check-changeset-no-major is doing real work
pr-automation.ymlWHICH LEVEL (:667 ff., maintainer ruling 2026-09-04 on #15294): afix(that changes a published package's public surface isminor; during the launch windowmajorstays refused and breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition. Both carriers are now present on the changeset.- The refusal is armed, not cited as cover: the guard stands down only in Changesets pre-mode, and
.changeset/pre.jsondoes not exist at this head or onorigin/main(--listreads backmode: (absent or unreadable)); probe X above proved the enforce branch fires. It is wired live atpr-automation.yml:942against$MERGE_BASE.
CI on this head (read, not reopened)
Check Changeset — the job hosting Require an ADR-0087 disposition on a declared-breaking changeset, now load-bearing — is success, as are all four Type Check jobs, Build Core, and every other completed job; Lint & Repo Gates and Test Core (1/6) were still in progress at read time.
Tier fuse — own file, read directly
This seat's own transcript, …/subagents/agent-ab984de2a074d02c2.jsonl (the file that carries this review's task prompt — not the dispatching session's transcript, which is what the prior review misread), parsed record by record: 48 of 48 type:"assistant" records carry a message.model stamp equal to CONTRACT_REVIEW_TIER (scripts/pm/dispatch-gates.mjs:9852, same value on origin/main and this head). Control, same parser, same run: a developer-seat transcript at 234/234 stamps of a different value; zero occurrences of the Agent-tool input alias literal in this file. This verdict is a tier verdict.
NOT MEASURED (reported as such, never as a pass)
- External consumers of
makeExecutionContextResolver(the serverless host composition, cloud#924): that repository is not attached; unchanged from the prior review. - The package's own emitted
dist/index.d.tswas not built here; the before/after declaration shapes were measured on a standalone reduction of the three signatures under the same TypeScript major, not on the built artifact. Lint & Repo GatesandTest Core (1/6)on this head: in progress at read time.
Settled, not reopened
Both clause-② limbs, the LATENT chain's last link, the absence of presence-keying in the reachable read set, the C4 independence pair, and the governed-surface check — verified PASS in the prior review and carried forward as settled.
Generated by Claude Code
Fixes #15747
The card asked for the GRADE before the repair, and the grade is settled first below. It holds: LATENT, on a re-derived measurement rather than on the card's word for it — and the five fields it recorded as NOT MEASURED are now measured.
1. The grade — LATENT, upheld
The card stated its own falsifier: "if an absent
principalKindis reachable as anything other than'human'on this face — or if any of the other five omitted fields acquires a reader that distinguishes absent from a real value — this becomes a live, security-relevant defect." Both halves were driven.principalKind— three legs, re-derivedplugin-security/src/security-plugin.ts:4696,const isAgent = context?.principalKind === 'agent''agent'permissionSetMemoKey(same file, :4631) foldscontext?.principalKind ?? nullinto the memo keyThe memo-key reader is not a second distinguisher:
permissionSetMemois aWeakMapkeyed on the context OBJECT, and each request builds a fresh one, so a changed key VALUE cannot collide across requests. It is compared only against the same object's earlier key.The load-bearing leg is a chain of single call sites, not an inference:
principalKind: 'agent'is emitted byassembleExecutionContext*only whenoauth?.clientIdis set;oauthis non-undefinedonly insideresolveExecutionContext(packages/runtime/src/security/resolve-execution-context.ts:116), underopts.acceptOAuthAccessToken;acceptOAuthAccessTokenhas exactly ONE production setter in the repo —packages/runtime/src/http-dispatcher.ts:565, gated on the/mcppath regex;makeExecutionContextResolvernever callsresolveExecutionContextat all. It resolves the session through better-authgetSession({ headers })and callsresolveUserAuthzGrantsdirectly, so the OAuth verification branch is not on this face's code path in any form.'guest'is unreachable for a different reason: the resolver answersundefinedwith no session, and all three handlers return their own no-session body before an envelope exists. So an absentprincipalKindon this face was reachable as'human'and nothing else.Both halves are now pinned as tests, so a future OAuth door here turns them red instead of silently promoting the hazard:
/auth/me/permissionsleavesprincipalKind: 'human'and never callsverifyMcpAccessToken— a method deliberately PRESENT on the fake auth service, which would have answered an agent principal had it been asked;PR #15859 — checked, and it is MEASURED, not assumed. Its second repair site is the
acceptOAuthAccessTokengate, which is exactly the flag above. It cannot reach this face: the flag is an option toresolveExecutionContext, a function this resolver does not call, and these three routes are never/mcp. Changing which prefix the dispatcher strips cannot introduce an OAuth branch into a resolver that has none. (Measured against #15859 as it stands open at base59953d5.)The other five — recorded NOT MEASURED, now measured
The envelope reaches exactly ONE consumer on these faces:
ISecurityService.resolvePermissionSetsForContext, from/auth/me/permissionsand/me/apps./auth/me/localizationhands it to nothing. That consumer's complete set of context reads ispositions,permissions,principalKind, the PRESENCE ofuserId, andcallerOrganizationId(organizationId ?? tenantId) — its own docblock says so and the body agrees. The handlers readuserId,tenantId,positions,permissions,systemPermissions,tabPermissions.onBehalfOfopCtx.context), reached by engine operations, never byresolvePermissionSetsForContextaudienceExecutionContext.audienceundefinedon every face (no transport resolves an external audience yet)accessTokenobjectql/engine.tsbuildSessionsurfaces it to hooksauthGateRestServer.enforceAuthoauthScopesruntime/src/domains/mcp.ts, to narrow tool families⇒ none of the five has a reader that can distinguish absent from a real value here. The grade stands at LATENT for all six fields.
One supporting measurement the conversion depends on: nothing in the reachable consumer keys on key PRESENCE.
hasOwnPropertyappears twice insecurity-plugin.tsand both are on data rows, never on the context. That is what makes the assembler'semit()— which DROPSundefined-valued keys — safe here.2. Dedup, re-run with a live control
The repo-scoped REST channel is 403 for this session (whole class;
ghabsent), so this went through one targeted MCPsearch_issues— channel switch declared.makeExecutionContextResolver/ hand-rolled ExecutionContext envelope /assemble-execution-context/principalKind/onBehalfOf/oauthScopes/ omitted fields,repo:objectstack-ai/objectstack is:open.makeExecutionContextResolverhand-rolls an ExecutionContext envelope and omits six fields of the closed entry set thatassemble-execution-context.tsexists to make unrepresentable #15747 itself. That hit IS the positive control — a card known to exist and known to match returned, so the result is a reading and not a silent zero. No duplicate.A second reading was needed for the HARD SERIAL claim check on the file being edited, and it came back EMPTY, so it got its own control on the same endpoint in the same session:
search_pull_requestsfor open PRs touchingcurrent-user-endpoints/makeExecutionContextResolver→ 0;/environments/, the prefix its own hint parser reads #15859 → 1 hit, fix(runtime): the dispatcher's scope strip matches/environments/, the prefix its own hint parser reads #15859.⇒ the 0 is a reading: no open PR claims
packages/plugins/plugin-hono-server/src/current-user-endpoints.ts. It was freed by PR #15745's merge, andgit log -1on the file confirms that merge is its most recent commit.3. The blast radius — measured on every affected face, before and after
This is what #15387 declined to take on, and why the card is its own card. Measured by capturing the context that ARRIVES at the real consumer, through the REAL registered routes (
registerCurrentUserEndpointson a realHonoapp, driven withapp.request()) — the same instrumentation #6071 used on the REST face./auth/me/permissionsprincipalKind,tabPermissionspresent-with-undefinedprincipalKind: 'human',tabPermissionsdropped as anundefineddecision/me/apps/auth/me/localizationuserId/tenantIdonly (since #15387)All three bodies are asserted as goldens in the new suite, and the
/auth/me/permissionsgolden was CORRECTED to the measured before-state (positions: ['org_member', 'everyone']) after my first guess at it was wrong — the point of those three cases is identity with what the face answered before, so a guessed golden would have been worthless.4. The repair shape, and the axes
Shape (i) — convert the resolver to
assemble-execution-context.ts.assemble-execution-context.tsexists to make this shape unrepresentable, by CLOSING the field set with a type; a hand-rolled literal beside it is the defect the module was built to prevent, and its own docblock names the two measured members of the class (两处手写的 ExecutionContext 组装已漂移:REST 传输不带principalKind/onBehalfOf,而 explain / security 会读它 #6071 field drift; 同族第三处组装:share-link 路由把授权信封裁成 4 个字段后直接当 enforcement context 喂给 engine.find ——group租户姿态下 Layer 0 墙恒判否 #6206 / dispatcher 面的 /share-links 把已解析完整的 ExecutionContext 重新裁成两个字段再喂给 enforcement —— 与 #6206 同一条 enforcement 路径的另一张脸 #6551 droppedaccessible_org_ids→ real 403s). Shape (ii) — fill six fields by hand — leaves the class intact: the next field added toExecutionContextis omitted here again, silently, and nothing says so. Shape (iii) — make the omission loud — still needs a human to act on the noise, and buys less than the compile error does.as anyis gone and a field added toExecutionContextfails to COMPILE here until this face decides it. That is the difference between a rule and a comment.@objectstack/corewas already a dependency and already imported by this very file; no new read, no new config, no new dependency. Every per-face divergence is withheld withundefined, so the runtime envelope gains exactly one key.Each withheld input is named on the record rather than omitted: no OAuth door; no localization (the endpoint reads that cascade itself since #15387 — resolving it here too would add a
sys_settingread to every request on all three routes and give one cascade two readings to keep in agreement); no session bearer (the REST face's stance); no auth gate (its consumer is the REST seam, andcore/security/auth-gate.tsallow-lists/me/apps+/me/localizationas routes a GATED user must still reach, so carrying it here would be an unread copy on a surface the gate deliberately does not close).5. The ablation — predicted in writing first, both limbs
Predictions were written before either limb ran, including which assertions stay GREEN and why. Each mutation was proven on disk by removed-text AND injected-marker counts, with a zero count treated as VOID; each restore ran under an
EXIT INT TERMtrap using absolute paths andgit checkout HEAD -- ABSOLUTE_PATH, proven by blob-hash equality against the HEAD blob AND an emptygit diff HEAD.principalKindagainprincipalKind⭐ What stays GREEN under limb A is the finding restated as a test. The three wire bodies keep answering identically while the envelope is wrong — because the omission has no reachable consequence on these faces, which is precisely why this card is graded LATENT. An ablation that reddened a wire body would mean the grade was wrong, not that the pin was strong.
⭐ What stays GREEN under limb B is the other half. The two key-set equalities survive, because the reference envelope is built from the SAME assembler and face and reference lose the field together. Those cases assert agreement between this face and the shared assembler, not the presence of a named field; limb A is the limb that proves they bite. Predicting that in advance is what separates the two limbs from one limb run twice.
Source-vs-dist, proven positively rather than asserted.
packages/plugins/plugin-hono-server/vitest.config.tsaliases@objectstack/coreto../../core/src/index.ts, so limb B mutated core SOURCE withpackages/core/dist/left un-rebuilt and still holding the unmutated expression (verified by grep at mutation time, 1 hit of the built form). The suite went RED ⇒ it resolves core to SOURCE. A GREEN limb B would have proved it was readingdist/and voided the whole limb.6. Verification — all on the final head
f457224b9ddnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackand harvested with--commands. The 44 bullet lines under "Local gates for this card" are the MATCHED block alone — the script's own reconciliation says that harvest is short by the 6 convention-triggered and 7 declared-whole-tree families. Exit codes captured before any pipe;2read as NOT WIRED and3as PREREQUISITE NOT MET, never as a pass. Re-derived after the change set was final.origin/mainat first derivation (STALE TREE banner);origin/mainwas merged in (never rebased) and the union re-derived on the merged head.check:type-check-debtneeds the workspace closure built, sopnpm exec turbo run buildover./packages/*+./packages/*/*ran first — 71/71, exit 0.@objectstack/plugin-hono-server22 files / 247 assertions green, including the 6 pre-existing current-user suites;@objectstack/hono2 files / 74 green (it aliases this package to SOURCE, so it exercises the change).@objectstack/plugin-hono-servergreen, and the new test file was confirmed INSIDE the type-checked program viatsc -p tsconfig.test.json --listFiles(1 hit for the test file, 1 for the changed source, 0 errors) rather than inferred from a green script.makeExecutionContextResolverhas ZERO in-repo consumers outside its own module (onlyhono-plugin.tscallsregisterCurrentUserEndpoints), measured by repo-wide grep acrosspackages/,examples/andapps/.pnpm lint=eslint . --no-inline-config, green in 88s under the shared verify lock (a shared-box figure, not an idle-box one) — no narrowing claimed or needed.7. Clause ② (契約複審) — declared, from the DELIVERED diff
Mechanical / path limb — YES.
packages/spec/src/**is not touched and the three endpoint BODIES are byte-identical, so nothing new crosses the wire. ButmakeExecutionContextResolverIS published (export * from './current-user-endpoints'on the package index), and this diff changes its published shape twice: the declared return type narrows fromanytoExecutionContext | undefined, and the returned envelope gainsprincipalKindwhile droppingtabPermissionswhen its value isundefined. In-repo consumers outside the module measure ZERO; consumers outside this repo cannot be measured from here, and the serverless host path composes pieces of this module directly (cloud#924). A narrowing on a published type with unmeasurable external consumers is not a clear call, so it is graded yes.Non-mechanizable conformance limb — YES. The measured answer is that no verdict changes:
isAgentwas false forundefinedand is false for'human', the'agent'arm is unreachable on this face, and the wire bodies are pinned identical. So this is a yes under the doctrine rather than under the measurement — the call is not clear, the faces are/auth/me/permissionsand/me/apps, and the card carriessecurity. A false yes self-corrects in a review round; a false no ships.⇒
needs:contract-reviewapplied to the card AND this PR together, with both label sets read back.Changeset level.
minor, notpatch: the pr-automation "WHICH LEVEL" rule keepspatchfor afix(that changes no public surface, and this one does change a published package's public surface. No BREAKING banner and no ADR-0087 disposition are claimed — the runtime behaviour is unchanged on every measured path, and grading the type narrowing as breaking is a judgement left to the contract reviewer rather than pre-empted here.8. Handed back, not acted on
None. Nothing out of scope was found that carried its own measurement.
🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Generated by Claude Code