Skip to content

fix(F167): a scheduler wake row was not a valid approval origin, so timer-woken sessions could never hand off - #1347

Closed
TERRYYYC wants to merge 6 commits into
zts212653:mainfrom
TERRYYYC:fix/f167-approval-origin-system-user
Closed

fix(F167): a scheduler wake row was not a valid approval origin, so timer-woken sessions could never hand off#1347
TERRYYYC wants to merge 6 commits into
zts212653:mainfrom
TERRYYYC:fix/f167-approval-origin-system-user

Conversation

@TERRYYYC

Copy link
Copy Markdown
Contributor

The bug

propose_session_handoff returned 500 Approval origin message owner mismatch deterministically — three consecutive calls, not a transient fault.

The asymmetry is what made it look like a flaky server rather than a rule:

Trigger for the invocation origin userId handoff
scheduler wake row (持球唤醒) scheduler ✗ 500
A2A cross-thread delivery real owner ✓ works

So 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:

// callback-propose-session-handoff-routes.ts
const originMessageId = record.originTriggerMessageId ?? record.a2aTriggerMessageId;

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, so ApprovalIngress.validateOrigin passed the threadId assertion and then failed origin.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 originRef to an authenticated InvocationRecord (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:

if (origin.userId !== draft.ownerUserId && !isSystemUserMessage(origin)) { throw ... }

isSystemUserMessage requires 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":

  1. 🔴→🟢 a scheduler-authored origin in the owner thread is accepted
  2. 🟢 an origin authored by another human user is still rejected
  3. 🟢 userId=scheduler carried by a cat-authored message is still rejected

RED was observed first, and confirmed red for the right reason:

✖ accepts a scheduler-authored origin in the owner thread
  Error: Approval origin message owner mismatch
      at ApprovalIngress.validateOrigin (ApprovalIngress.js:175:19)
Scope Before After
approval-ingress.test.js 15/16 (1 red) 16/16
approval-hub/*.test.js 299/299
session-handoff-* + propose-session-handoff-route 54/54

The pre-existing rejects ... cross-owner origins case 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 mechanical non_comment_changed_lines=0 check on the comment-only increments.

Commits are not amended — each of the reviewer's verdicts stays anchored to the SHA it was given for:

  • 7d4870898 fix + tests — APPROVE
  • 7843fb6fa boundary comment — APPROVE + P3
  • 2bad996bc review credit + scope — APPROVE + P3
  • bd609336d caller-invariant attribution — APPROVE + P3
  • e5b6b1c96 isolation stated once as a conjunction — APPROVE, converged

Three 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

Ragdoll-Opus-5 and others added 5 commits August 12, 2026 15:30
…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>
@TERRYYYC
TERRYYYC requested a review from zts212653 as a code owner August 12, 2026 13:45
@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@zts212653

Copy link
Copy Markdown
Owner

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:

  1. Please open or link a clowder-ai issue for this bug, including the exact reproduction, expected behavior, and how the scheduler-authored wake row is produced. Our inbound process requires an accepted issue before a new community patch enters merge review.
  2. The patch relaxes ApprovalIngress.validateOrigin() for every message-origin producer, while the security argument in the code only establishes authenticated origin binding for the F225 session-handoff caller. Please either scope the exemption to a server-attested/F225 origin, or prove that every producer which can reach this shared ingress establishes the same owner/thread/message binding. An integration test from the authenticated F225 callback record through ApprovalIngress would make that boundary concrete.

One related correction: the new unit-test comment says the threadId equality check alone pins the origin to the caller's own thread. It does not; the production comment correctly notes that this only becomes a tenancy boundary when combined with authenticated caller binding. Please make those explanations consistent.

I’m marking this needs-info for now. Once the issue is linked and the shared-boundary scope is resolved, we can continue with exact-HEAD review. The implementation remains with you; we are not taking over the branch.

@zts212653 zts212653 added bug Something isn't working triaged Maintainer reviewed, replied, and made an initial triage decision needs-info Waiting for additional information from reporter labels Aug 12, 2026
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>
@TERRYYYC

Copy link
Copy Markdown
Contributor Author

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.

@TERRYYYC TERRYYYC closed this Aug 12, 2026
TERRYYYC pushed a commit to TERRYYYC/YYCcat_cafe that referenced this pull request Aug 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working needs-info Waiting for additional information from reporter triaged Maintainer reviewed, replied, and made an initial triage decision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants