fix(F167): a scheduler wake row was not a valid approval origin, so timer-woken sessions could never hand off - #1347
Conversation
…imer-woken sessions could never hand off
propose_session_handoff anchors its approval card on the message that triggered
the invocation:
const originMessageId = record.originTriggerMessageId ?? record.a2aTriggerMessageId;
For any long-running session that message IS the scheduler's wake row ("持球唤醒"),
which is persisted as userId='scheduler' / catId=null. ApprovalIngress.validateOrigin
then compared origin.userId against the proposal's ownerUserId and threw:
500 Approval origin message owner mismatch
So the sessions most in need of a handoff -- the ones long enough to be driven by
timers, carrying the heaviest context -- were exactly the ones that could not
propose one. Observed live: three consecutive attempts from an opus5 author line,
all three woken by the scheduler, all three 500. The same proposal from an
A2A-triggered turn would have succeeded, because a cross-thread delivery is
persisted under the real owner's userId. That asymmetry is what made this look
like an intermittent server fault rather than a rule.
The check was not protecting what it appeared to protect. Cross-tenant isolation
is the assertion one line ABOVE: the origin must live in the caller's own thread,
and originRef.threadId comes from the authenticated callback record. What the
userId comparison adds is a narrower rule about who may have authored a row
inside that already-verified thread -- and a system pseudo-user speaking in your
own thread is not another tenant.
Fix: exempt system-authored rows via isSystemUserMessage, the store layer's
existing predicate for this exact distinction. Deliberately reused rather than
re-derived: it requires BOTH a system userId AND a system/null catId, so a
cat-authored row wearing a system userId stays rejected. A second private
definition of "is this the system" is how two such definitions drift apart.
Evidence, not inference. The live wake row was read back from the store before
any code was changed:
{ id: 0001786521900305-..., userId: "scheduler", catId: null,
threadId: thread_mslv8bbw8pbazsz2 }
with the thread owned by default-user -- same thread (so the threadId assertion
passed), different userId (so this one threw).
Tests: three cases, not one. The new red case pins the scheduler origin; the
other two exist so that the fix cannot degrade into deleting the check --
another HUMAN owner must still be rejected, and a system userId carried by a
cat-authored message must still be rejected. Verified red before green: exactly
one failure, and it failed on `Approval origin message owner mismatch` thrown
from validateOrigin, not on anything incidental.
approval-ingress.test.js 16/16 (1 failing before the fix)
approval-hub/*.test.js 299/299
session-handoff-* 54/54
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not a check this class performs 砚砚's review approved the fix and added one boundary worth more than a chat message: the preceding threadId assertion is a CALLER invariant, not an independent authorization check ApprovalIngress runs against an arbitrary draft. My comment said "cross-tenant isolation is the assertion ABOVE", which reads as though this class guarantees it. It does not. The guarantee holds because the only producer of this draft builds it from an authenticated InvocationRecord, so a request body cannot rewrite threadId, userId or the trigger messageId. Left in the review thread, that distinction decays; the next person to add a producer here would read my comment and assume protection that this class never provided. Written next to the exemption, it states what the exemption assumes. Comment only — no behaviour change. Re-verified: approval-hub + session-handoff 353/353. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… class has one producer Two corrections to the comment added in 7843fb6, one of them mine to own. 1. I credited the review to 砚砚. The reviewer was @codex-luna. While reading sessionHandoffPropose.ts I absorbed its existing '(砚砚 P2)' annotations and reproduced that name without checking who had actually reviewed THIS change. Attribution is not decoration — a wrong name sends the next reader to the wrong cat for the reasoning behind the exemption. 2. @codex-luna's non-blocking P3, which is more than wording. I wrote 'the only producer of this draft'. Read as a statement about the class it is false: ApprovalIngress serves many producers (F128/F139/F193/F221/F225/F231/F246/ F260/F276). Worse, it is false in the dangerous direction — it invites the assumption that every path into this ingress carries an authenticated binding. Now scoped explicitly to the F225 handoff path, with the class-level caveat stated rather than implied, and 're-establish' replaced by his more precise 'establish and validate an authenticated owner/thread/origin binding before calling this ingress'. Worth naming the shape: the comment existed to prevent one misreading and introduced another one level up. Same failure mode, one layer removed — which is exactly what this branch keeps finding elsewhere. Comment only — no behaviour change. Re-verified: approval-hub + session-handoff 353/353, git diff --check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nding, not the threadId comparison @codex-luna's third pass caught that my previous wording said the opposite of the truth about this class. I wrote 'the threadId equality is a CALLER invariant, not an authorization check this class performs'. But this class DOES perform that comparison, one line above. What it cannot verify is whether the originRef it was handed — its threadId, and the ownerUserId compared below — came from an authenticated record at all. That binding is the caller invariant; the comparison is ours. So the sentence attributed our own check to the caller, and left the thing the caller actually owns unnamed. A reader auditing this exemption would have looked for the wrong guarantee in the wrong place. A comment that states the inverse of what the code does is worse than no comment: it survives review by sounding careful. Third correction to the same comment, each one from a reviewer pass, each moving it closer to what the code actually does. Worth noting that all three were about the SAME sentence — the prose kept drifting toward whatever sounded reassuring rather than what was checked where. Comment only — no behaviour change. Re-verified: approval-hub + session-handoff 353/353, git diff --check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…half alone @codex-luna's fourth pass caught that the opening sentence still carried the exact error the last three commits fixed further down: "Cross-tenant isolation rests on the assertion ABOVE" credits tenancy to the threadId comparison. It does not hold alone. That comparison only proves the origin message lives in the thread the draft NAMES — a draft naming a thread of its own choosing satisfies it trivially. It carries weight only because the caller has already bound originRef.threadId/.messageId and ownerUserId to an authenticated record, which fixes WHICH thread may be named. Neither half is sufficient; isolation is the conjunction. The reason this took four rounds is the shape of the fix, not the difficulty of the claim. The comment stated the same security argument three times in prose, so each round I corrected the instance the reviewer quoted and left its paraphrases standing one paragraph up. Patching instances of a class. So this states the argument ONCE, as numbered parts (1) CALLER / (2) THIS CLASS, with what each cannot do written next to it, and deletes the restatements. There is no longer a second place for the claim to drift, which is the only version of this fix that ends the series rather than extending it. Comment only — no behaviour change. Re-verified: approval-hub + session-handoff 353/353 pass 0 fail, comment-only diff asserted mechanically (every changed line matches ^[+-]\s*//), git diff --check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
To use Codex here, create an environment for this repo. |
|
Thanks for the detailed reproduction and the focused tests. The timer-wake failure mode is plausible, but this PR is not ready to enter formal merge review yet. Two direction gates need to be closed first:
One related correction: the new unit-test comment says the I’m marking this |
CI Lint failed on `pnpm check`: biome wanted the two `assert.rejects` calls in the guard tests collapsed onto one line. Formatting only — no assertion, no matcher, and no behaviour changed. Worth recording why CI caught this and I did not: my local gate was `pnpm build` + the three test suites. The repo's own `pnpm check` — which is what the Lint job runs — was never in my loop. Passing the tests I chose is not the same as passing the checks the repo requires, and only the second one is the actual contract. Same shape as the rest of this branch: I verified the thing I was thinking about rather than the thing that gates the merge. Confined to the two tests this branch added; no unrelated files reformatted. Re-verified: pnpm check exit 0, approval-hub + session-handoff 353 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Per maintainer direction, this work is being tracked in #1348 first. Closing this PR without merging; the branch and commits remain available for the issue workflow. |
…e the binding Maintainer gate 2 on PR zts212653#1347: the exemption relaxed validateOrigin for EVERY message-origin producer, while the security argument only established the authenticated origin binding for the F225 session-handoff caller. The exemption was as wide as the shared ingress; the justification covered one path. So the binding is now DECLARED per producer and ENFORCED, not documented: `systemOriginExemption: 'server_attested' | 'forbidden'` on the producer catalog entry, consulted in validateOrigin. Only F225 is attested — its adapter reads originTriggerMessageId/a2aTriggerMessageId, threadId and userId off the authenticated InvocationRecord, and no request body can rewrite them. The field is REQUIRED, so a new producer cannot inherit the exemption by omission — leaving it out is a compile error, not a silent default. My own test was the evidence and I misread it. makeDraft() defaults to F128, so `accepts a scheduler-authored origin` was exercising the over-broad exemption on a producer that never proved the binding — and its green read as confirmation. That test now names F225 explicitly, and a new case asserts F128 with the same scheduler origin is still REJECTED. Proven load-bearing by mutation: forcing the guard true fails exactly that one case (16/17), so the scoping and no scoping are distinguishable. Also gate 3: the unit-test comment still claimed the threadId assertion alone pins the origin to the caller's own thread. That is the original wrong sentence, corrected four times in ApprovalIngress.ts across four review rounds and left standing here the whole time — every round fixed the instance being quoted and nobody grepped for the paraphrase. Worse, the falsifiability criterion I handed the reviewer was scoped "if this claim still appears in the FILE", which excluded the one place it did. A test written to pass. Verified: pnpm check exit 0 (the repo gate, not just my own), build clean, approval-hub + session-handoff 354 pass / 0 fail, mutation experiment as above. Refs zts212653#1348 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bug
propose_session_handoffreturned500 Approval origin message owner mismatchdeterministically — three consecutive calls, not a transient fault.The asymmetry is what made it look like a flaky server rather than a rule:
userIdschedulerSo the sessions that need handoff most — the long-running ones, driven by timers, carrying the heaviest context — were the only ones that could never hand off.
Root cause
A handoff proposal anchors its approval card on the message that triggered the invocation:
For a long-running session that message is the scheduler's wake row. Read back from the store before changing any code:
{ "userId": "scheduler", "catId": null, "threadId": "thread_mslv8bbw8pbazsz2" }The thread belongs to
default-user, soApprovalIngress.validateOriginpassed the threadId assertion and then failedorigin.userId !== draft.ownerUserId.Why this is a bug, not the check working
The userId comparison was not protecting what it appeared to protect. Isolation on this path is a conjunction: the caller binds
originRefto an authenticatedInvocationRecord(fixing which thread may be named), and this class checks the stored origin is consistent with it. What the userId comparison additionally decided was only who may have authored a row inside an already-verified thread — and a system pseudo-user speaking in your own thread is not another tenant.Fix
Reuse the store layer's existing predicate rather than deriving a second definition:
isSystemUserMessagerequires both a system userId (scheduler/system) and a system/null catId, so a cat-authored row wearing a system userId stays rejected. Two parallel definitions of "is this the system" is how they start to drift.Tests — three, not one
The second and third exist so this fix cannot degrade into "delete the check":
userId=schedulercarried by a cat-authored message is still rejectedRED was observed first, and confirmed red for the right reason:
approval-ingress.test.jsapproval-hub/*.test.jssession-handoff-*+propose-session-handoff-routeThe pre-existing
rejects ... cross-owner originscase stays green throughout — direct evidence the cross-user protection was not weakened.Review provenance
Reviewed by @codex-luna (Maine Coon — cross-family, non-author) across four rounds. Final verdict on
e5b6b1c96: APPROVE, no P1/P2/P3, with independent verification (build, 60/60, 299/299, F225 route 10/10,git diff --check) and a mechanicalnon_comment_changed_lines=0check on the comment-only increments.Commits are not amended — each of the reviewer's verdicts stays anchored to the SHA it was given for:
7d4870898fix + tests — APPROVE7843fb6faboundary comment — APPROVE + P32bad996bcreview credit + scope — APPROVE + P3bd609336dcaller-invariant attribution — APPROVE + P3e5b6b1c96isolation stated once as a conjunction — APPROVE, convergedThree of those rounds landed on the same sentence. The reason was the shape of the fix, not the difficulty of the claim: the comment stated the same security argument three times in prose, so each round corrected the quoted instance and left its paraphrases standing. The final commit states it once, as numbered parts, so there is no second site for it to drift.
Scope
Behaviour change is one condition in
ApprovalIngress.validateOrigin. No schema migration, no new wire, no external contract change. The other four commits are comment-only.Merge is not authorized by this author line — merge-gate is still pending.
🤖 Generated with Claude Code