Summary
Long-running sessions resumed by a scheduler wake cannot call propose_session_handoff. The callback returns:
500 Approval origin message owner mismatch
This is deterministic for scheduler-driven turns, while the same handoff from an A2A-triggered turn succeeds.
Reproduction
A timer-woken invocation uses its scheduler wake row as the exact origin message. The persisted row has the shape:
{ "userId": "scheduler", "catId": null, "threadId": "<owner thread>" }
ApprovalIngress.validateOrigin verifies that the stored message belongs to originRef.threadId, then rejects it because origin.userId (scheduler) differs from the proposal owner user.
Root cause
The approval origin has two separate properties:
- The callback producer must bind
originRef.threadId, originRef.messageId, and ownerUserId to the authenticated invocation record.
- The ingress must verify that the stored origin message is internally consistent with that bound
originRef.
A system pseudo-user speaking in the already-bound owner thread is not a cross-tenant origin. The existing isSystemUserMessage predicate already requires both a system user ID (scheduler or system) and catId null/system.
The defect is therefore that the owner comparison is applied as if property 1 did not exist, on a path where it does — but, as the fix below spells out, property 1 holds per producer and not for the shared ingress as a whole. That distinction is what the original proposal got wrong.
Proposed fix
Revised 2026-08-13 (reporter). The original proposal — a global isSystemUserMessage bypass inside the ingress — is superseded and preserved verbatim at the bottom of this issue for provenance. It was rejected at PR #1347 gate 2: ApprovalIngress serves nine producers, so an ingress-level bypass is as wide as the shared ingress, while the safety argument only covered one caller (F225). Maintainer direction on PR #1349 (2026-08-12T14:57Z) accepted the catalog-scoped design below instead. This section now describes that design; the acceptance criteria were re-derived against it.
Cross-tenant isolation on this path is a conjunction of two properties, and neither is sufficient alone:
- Caller-side binding — the producer derives
originRef.threadId, originRef.messageId and ownerUserId from an authenticated InvocationRecord that a request body cannot rewrite.
- Ingress-side consistency —
validateOrigin verifies the stored origin message really does live in the thread the draft names.
Property 2 is trivially satisfiable by a draft that names a thread of its own choosing; it carries weight only because property 1 fixes which thread may be named. And property 1 is a property of a path, never of the shared ingress class. So the exemption must be gated per producer, not granted at the ingress.
Add a required field to the producer catalog:
systemOriginExemption: 'server_attested' | 'forbidden';
and make the ingress a conjunction of that capability gate with the existing store-layer predicate:
const systemOriginExempt =
approvalProducerMeta(draft.producerId).systemOriginExemption === 'server_attested';
if (origin.userId !== draft.ownerUserId && !(systemOriginExempt && isSystemUserMessage(origin))) {
throw new Error('Approval origin message owner mismatch');
}
What this buys over the global bypass:
- Omission is a compile error, not an inherited exemption. The field is required, so a producer added later cannot silently acquire the bypass by saying nothing.
forbidden is the honest default, and each current forbidden value carries its own recorded reason rather than one blanket sentence: F139 and F292 never reach this predicate at all (event origin / signal admission), F221 is genuinely unbound because sourceMessageId may be supplied by the request body, and F276 needs deferred/scheduler route coverage before it could be attested.
isSystemUserMessage is reused, not re-derived. It already 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 the two drift apart.
Scope limit, stated explicitly. systemOriginExemption is a capability gate over a declaration. It records which producers claim property 1; it is not a runtime proof that the named adapter actually binds, and a wrong server_attested value would not be caught at the ingress. It narrows the blast radius from "every message-origin producer" to "the ones someone audited and signed for" — a real reduction, not a verification. The proof has to come from the per-entry creation-site audit trail plus route-level coverage.
Acceptance criteria
- A scheduler/system-authored origin inside the authenticated owner thread is accepted for a producer whose
systemOriginExemption is server_attested (F225, the reported case).
- The same origin is rejected for a producer whose value is
forbidden.
- An origin authored by another human owner remains rejected, for every producer.
userId=scheduler/system carried by a message with a real cat catId remains rejected — the exemption must not be reachable by a cat wearing a system user ID.
systemOriginExemption is a required catalog field: adding a producer without deciding its value fails to compile.
- Each
server_attested entry records an audit note naming who walked the creation site and whether the binding is DIRECT (the body may not name a different message) or TRANSITIVE (the row the originRef is built from was itself written from the record).
- Existing Approval Hub and session-handoff tests remain green.
- No schema or wire-contract change is required.
Follow-up with durable custody
The end-to-end proof that an authenticated F225 callback reaches ApprovalIngress with a scheduler-authored origin and is accepted is not in the reference PR. It is tracked in #1350 so the caller-side attestation does not rest on the catalog declaration alone — required stops an omission, it cannot stop a wrong declaration. The five ingress-level tests in PR #1349 prove the gate decides correctly; #1350 covers the premise that the F225 route actually binds.
Reference implementation
Superseded original proposal (verbatim, kept for provenance)
Allow the owner mismatch only when the stored origin satisfies isSystemUserMessage:
if (origin.userId !== draft.ownerUserId && !isSystemUserMessage(origin)) {
throw new Error('Approval origin message owner mismatch');
}
Keep the authenticated origin binding in each producer path; do not treat the ingress consistency check as a substitute for that binding.
Original acceptance criteria:
- Scheduler/system origin messages in the authenticated owner thread are accepted.
- An origin authored by another human owner remains rejected.
userId=scheduler or system with a real cat catId remains rejected.
- Existing Approval Hub and session-handoff tests remain green.
- No schema or wire-contract change is required.
Why it was rejected: the exemption was granted at the shared ingress, so it applied to every message-origin producer, while the argument for it (an authenticated InvocationRecord fixing thread + owner) had only been walked for one of them. The last bullet of the original criteria is the tell — it lists no per-producer distinction at all.
Summary
Long-running sessions resumed by a scheduler wake cannot call
propose_session_handoff. The callback returns:This is deterministic for scheduler-driven turns, while the same handoff from an A2A-triggered turn succeeds.
Reproduction
A timer-woken invocation uses its scheduler wake row as the exact origin message. The persisted row has the shape:
{ "userId": "scheduler", "catId": null, "threadId": "<owner thread>" }ApprovalIngress.validateOriginverifies that the stored message belongs tooriginRef.threadId, then rejects it becauseorigin.userId(scheduler) differs from the proposal owner user.Root cause
The approval origin has two separate properties:
originRef.threadId,originRef.messageId, andownerUserIdto the authenticated invocation record.originRef.A system pseudo-user speaking in the already-bound owner thread is not a cross-tenant origin. The existing
isSystemUserMessagepredicate already requires both a system user ID (schedulerorsystem) andcatIdnull/system.The defect is therefore that the owner comparison is applied as if property 1 did not exist, on a path where it does — but, as the fix below spells out, property 1 holds per producer and not for the shared ingress as a whole. That distinction is what the original proposal got wrong.
Proposed fix
Cross-tenant isolation on this path is a conjunction of two properties, and neither is sufficient alone:
originRef.threadId,originRef.messageIdandownerUserIdfrom an authenticatedInvocationRecordthat a request body cannot rewrite.validateOriginverifies the stored origin message really does live in the thread the draft names.Property 2 is trivially satisfiable by a draft that names a thread of its own choosing; it carries weight only because property 1 fixes which thread may be named. And property 1 is a property of a path, never of the shared ingress class. So the exemption must be gated per producer, not granted at the ingress.
Add a required field to the producer catalog:
and make the ingress a conjunction of that capability gate with the existing store-layer predicate:
What this buys over the global bypass:
forbiddenis the honest default, and each currentforbiddenvalue carries its own recorded reason rather than one blanket sentence: F139 and F292 never reach this predicate at all (event origin / signal admission), F221 is genuinely unbound becausesourceMessageIdmay be supplied by the request body, and F276 needs deferred/scheduler route coverage before it could be attested.isSystemUserMessageis reused, not re-derived. It already requires both a systemuserIdand a system/nullcatId, so a cat-authored row wearing a systemuserIdstays rejected. A second, private definition of "is this the system" is how the two drift apart.Scope limit, stated explicitly.
systemOriginExemptionis a capability gate over a declaration. It records which producers claim property 1; it is not a runtime proof that the named adapter actually binds, and a wrongserver_attestedvalue would not be caught at the ingress. It narrows the blast radius from "every message-origin producer" to "the ones someone audited and signed for" — a real reduction, not a verification. The proof has to come from the per-entry creation-site audit trail plus route-level coverage.Acceptance criteria
systemOriginExemptionisserver_attested(F225, the reported case).forbidden.userId=scheduler/systemcarried by a message with a real catcatIdremains rejected — the exemption must not be reachable by a cat wearing a system user ID.systemOriginExemptionis a required catalog field: adding a producer without deciding its value fails to compile.server_attestedentry records an audit note naming who walked the creation site and whether the binding is DIRECT (the body may not name a different message) or TRANSITIVE (the row theoriginRefis built from was itself written from the record).Follow-up with durable custody
The end-to-end proof that an authenticated F225 callback reaches
ApprovalIngresswith a scheduler-authored origin and is accepted is not in the reference PR. It is tracked in #1350 so the caller-side attestation does not rest on the catalog declaration alone —requiredstops an omission, it cannot stop a wrong declaration. The five ingress-level tests in PR #1349 prove the gate decides correctly; #1350 covers the premise that the F225 route actually binds.Reference implementation
Superseded original proposal (verbatim, kept for provenance)
Allow the owner mismatch only when the stored origin satisfies
isSystemUserMessage:Keep the authenticated origin binding in each producer path; do not treat the ingress consistency check as a substitute for that binding.
Original acceptance criteria:
userId=schedulerorsystemwith a real catcatIdremains rejected.Why it was rejected: the exemption was granted at the shared ingress, so it applied to every message-origin producer, while the argument for it (an authenticated
InvocationRecordfixing thread + owner) had only been walked for one of them. The last bullet of the original criteria is the tell — it lists no per-producer distinction at all.