From 7d48708980463a1b68a83298e8b585fc5ca2f3bc Mon Sep 17 00:00:00 2001 From: Ragdoll-Opus-5 Date: Wed, 12 Aug 2026 15:30:12 +0300 Subject: [PATCH 1/6] fix(F167): a scheduler wake row was not a valid approval origin, so timer-woken sessions could never hand off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../domains/approval-hub/ApprovalIngress.ts | 23 +++++++- .../approval-hub/approval-ingress.test.js | 55 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/api/src/domains/approval-hub/ApprovalIngress.ts b/packages/api/src/domains/approval-hub/ApprovalIngress.ts index 597b661495..e3d8c4192d 100644 --- a/packages/api/src/domains/approval-hub/ApprovalIngress.ts +++ b/packages/api/src/domains/approval-hub/ApprovalIngress.ts @@ -2,6 +2,7 @@ import type { ApprovalEnvelope, ApprovalOriginRef, ApprovalProducerId, CatId, Ri import { approvalProducerMeta, validateApprovalEnvelope, validateApprovalOriginRef } from '@cat-cafe/shared'; import type { SocketManager } from '../../infrastructure/websocket/index.js'; import type { IMessageStore, StoredMessage } from '../cats/services/stores/ports/MessageStore.js'; +import { isSystemUserMessage } from '../cats/services/stores/visibility.js'; import { approvalCardIdempotencyKey, buildApprovalCardBlock } from './buildApprovalCardBlock.js'; import type { ApprovalPublicationStore } from './ports/ApprovalPublicationStore.js'; @@ -218,7 +219,27 @@ export class ApprovalIngress { const origin = await this.deps.messageStore.getById(draft.originRef.messageId); if (!origin || origin.deletedAt || origin._tombstone) throw new Error('Approval origin message not found'); if (origin.threadId !== draft.originRef.threadId) throw new Error('Approval origin message thread mismatch'); - if (origin.userId !== draft.ownerUserId) throw new Error('Approval origin message owner mismatch'); + // Cross-tenant isolation is the assertion ABOVE: the origin must live in the + // caller's own thread. What remains here is a narrower question — who may have + // authored a row inside that thread — and a system pseudo-user speaking in your + // own thread is not another tenant. + // + // Without the exemption this rejected precisely the sessions that need it most. + // A session-handoff proposal anchors on the message that triggered the + // invocation, and for any long-running session that message IS the scheduler's + // wake row ("持球唤醒"), persisted as userId='scheduler' / catId=null. So a + // timer-woken cat could never hand off, while the same proposal from an + // A2A-triggered turn (authored by the real owner) went through — the failure + // was invisible except as a 500 with no actionable text. + // + // isSystemUserMessage is the store layer's existing predicate for exactly this + // distinction, and it is deliberately reused rather than re-derived here: 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 the two drift apart. + if (origin.userId !== draft.ownerUserId && !isSystemUserMessage(origin)) { + throw new Error('Approval origin message owner mismatch'); + } } private async findPersistedCard( diff --git a/packages/api/test/approval-hub/approval-ingress.test.js b/packages/api/test/approval-hub/approval-ingress.test.js index bb1c4f97a6..9dd8ba14fe 100644 --- a/packages/api/test/approval-hub/approval-ingress.test.js +++ b/packages/api/test/approval-hub/approval-ingress.test.js @@ -41,7 +41,7 @@ class FakePublicationStore { } } -function appendOrigin(messageStore) { +function appendOrigin(messageStore, authorOverrides = {}) { messageStore.append({ userId: ownerUserId, catId: null, @@ -49,6 +49,7 @@ function appendOrigin(messageStore) { mentions: [], timestamp: 1_721_111_110_000, threadId: originRef.threadId, + ...authorOverrides, }); const stored = messageStore.getByThread(originRef.threadId, 1)[0]; stored.id = originRef.messageId; @@ -76,9 +77,9 @@ function makeDraft(overrides = {}) { }; } -function makeHarness() { +function makeHarness(originAuthorOverrides = {}) { const messageStore = new MessageStore(); - appendOrigin(messageStore); + appendOrigin(messageStore, originAuthorOverrides); const broadcasts = []; const userEvents = []; const socketManager = { @@ -100,6 +101,54 @@ function findApprovalCard(messageStore) { } describe('ApprovalIngress', () => { + // F167 — a session-handoff proposal anchors on the message that triggered the + // invocation, and for a long-running session that message IS the scheduler's + // wake row ("持球唤醒"), persisted under the `scheduler` system pseudo-user with + // `catId: null`. A bare `origin.userId !== ownerUserId` check therefore rejected + // exactly the sessions most in need of a handoff: every attempt driven by a + // timer wake failed with "Approval origin message owner mismatch", while the + // same proposal from an A2A-triggered turn (userId = the real owner) succeeded. + // + // The cross-user protection does not live in this comparison — the preceding + // threadId assertion already pins the origin to the caller's own thread. What + // this one adds is a rule about WHO may author a row inside that thread, and a + // system pseudo-user speaking in your own thread is not another tenant. + it('accepts a scheduler-authored origin in the owner thread', async () => { + const harness = makeHarness({ userId: 'scheduler', catId: null }); + const store = new FakePublicationStore(); + + const envelope = await harness.ingress.publish(makeDraft(), store); + + assert.equal(store.publication.state, 'anchored'); + assert.equal(envelope.approvalCardRef.threadId, 'source-thread'); + }); + + // The exemption must not become a hole. A different HUMAN owner is a genuine + // cross-tenant anchor and stays rejected — without this, the fix above would be + // indistinguishable from deleting the check. + it('still rejects an origin authored by another human user', async () => { + const harness = makeHarness({ userId: 'user-2', catId: null }); + const store = new FakePublicationStore(); + + await assert.rejects( + () => harness.ingress.publish(makeDraft(), store), + /Approval origin message owner mismatch/, + ); + }); + + // isSystemUserMessage requires BOTH a system userId AND a system/null catId, so + // a cat-authored row wearing a system userId is not a system message. Pinning it + // here keeps the exemption tied to that predicate rather than to the userId alone. + it('rejects a system userId carried by a cat-authored message', async () => { + const harness = makeHarness({ userId: 'scheduler', catId: 'codex-sol' }); + const store = new FakePublicationStore(); + + await assert.rejects( + () => harness.ingress.publish(makeDraft(), store), + /Approval origin message owner mismatch/, + ); + }); + it('persists one card, commits its exact envelope, then broadcasts', async () => { const harness = makeHarness(); const store = new FakePublicationStore(); From 7843fb6fa978b126b2a454a926c748f0932240b9 Mon Sep 17 00:00:00 2001 From: Ragdoll-Opus-5 Date: Wed, 12 Aug 2026 15:59:08 +0300 Subject: [PATCH 2/6] docs(F167): record that the threadId equality is a caller invariant, not a check this class performs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 砚砚'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 --- packages/api/src/domains/approval-hub/ApprovalIngress.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/api/src/domains/approval-hub/ApprovalIngress.ts b/packages/api/src/domains/approval-hub/ApprovalIngress.ts index e3d8c4192d..c027ea6a0d 100644 --- a/packages/api/src/domains/approval-hub/ApprovalIngress.ts +++ b/packages/api/src/domains/approval-hub/ApprovalIngress.ts @@ -224,6 +224,14 @@ export class ApprovalIngress { // authored a row inside that thread — and a system pseudo-user speaking in your // own thread is not another tenant. // + // Read that precisely (砚砚 review): the threadId equality is a CALLER + // invariant, not an authorization check this class performs on an arbitrary + // draft. It holds today because the only producer of this draft builds it from + // an authenticated InvocationRecord — request bodies cannot rewrite threadId, + // userId or the trigger messageId. A future producer that derives a draft from + // untrusted input must re-establish that binding itself; the exemption below + // assumes it rather than proving it. + // // Without the exemption this rejected precisely the sessions that need it most. // A session-handoff proposal anchors on the message that triggered the // invocation, and for any long-running session that message IS the scheduler's From 2bad996bcf5c0a298f3434f6acce8a0627d08f27 Mon Sep 17 00:00:00 2001 From: Ragdoll-Opus-5 Date: Wed, 12 Aug 2026 16:02:51 +0300 Subject: [PATCH 3/6] docs(F167): fix a misattributed review credit, and stop implying this class has one producer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the comment added in 7843fb6fa, 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 --- .../domains/approval-hub/ApprovalIngress.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/api/src/domains/approval-hub/ApprovalIngress.ts b/packages/api/src/domains/approval-hub/ApprovalIngress.ts index c027ea6a0d..676926feee 100644 --- a/packages/api/src/domains/approval-hub/ApprovalIngress.ts +++ b/packages/api/src/domains/approval-hub/ApprovalIngress.ts @@ -224,13 +224,17 @@ export class ApprovalIngress { // authored a row inside that thread — and a system pseudo-user speaking in your // own thread is not another tenant. // - // Read that precisely (砚砚 review): the threadId equality is a CALLER - // invariant, not an authorization check this class performs on an arbitrary - // draft. It holds today because the only producer of this draft builds it from - // an authenticated InvocationRecord — request bodies cannot rewrite threadId, - // userId or the trigger messageId. A future producer that derives a draft from - // untrusted input must re-establish that binding itself; the exemption below - // assumes it rather than proving it. + // Read that precisely (@codex-luna's review): the threadId equality is a + // CALLER invariant, not an authorization check this class performs on an + // arbitrary draft. It holds on the F225 session-handoff path because that + // producer builds the draft from an authenticated InvocationRecord — a request + // body cannot rewrite threadId, userId or the trigger messageId. + // + // ApprovalIngress serves several producers, so that is a property of the F225 + // path and NOT of this class. Any producer deriving a draft from untrusted + // input must itself establish and validate an authenticated + // owner/thread/origin binding before calling this ingress; the exemption below + // assumes that binding rather than proving it. // // Without the exemption this rejected precisely the sessions that need it most. // A session-handoff proposal anchors on the message that triggered the From bd609336d53b1de4c7f30b9d966f78b78ea3b4f5 Mon Sep 17 00:00:00 2001 From: Ragdoll-Opus-5 Date: Wed, 12 Aug 2026 16:06:49 +0300 Subject: [PATCH 4/6] =?UTF-8?q?docs(F167):=20name=20the=20caller=20invaria?= =?UTF-8?q?nt=20correctly=20=E2=80=94=20it=20is=20the=20origin=20binding,?= =?UTF-8?q?=20not=20the=20threadId=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 --- .../domains/approval-hub/ApprovalIngress.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/api/src/domains/approval-hub/ApprovalIngress.ts b/packages/api/src/domains/approval-hub/ApprovalIngress.ts index 676926feee..faa15aaca3 100644 --- a/packages/api/src/domains/approval-hub/ApprovalIngress.ts +++ b/packages/api/src/domains/approval-hub/ApprovalIngress.ts @@ -219,16 +219,18 @@ export class ApprovalIngress { const origin = await this.deps.messageStore.getById(draft.originRef.messageId); if (!origin || origin.deletedAt || origin._tombstone) throw new Error('Approval origin message not found'); if (origin.threadId !== draft.originRef.threadId) throw new Error('Approval origin message thread mismatch'); - // Cross-tenant isolation is the assertion ABOVE: the origin must live in the - // caller's own thread. What remains here is a narrower question — who may have - // authored a row inside that thread — and a system pseudo-user speaking in your - // own thread is not another tenant. + // Cross-tenant isolation rests on the assertion ABOVE: the origin must live in + // the thread the draft names. What remains here is a narrower question — who + // may have authored a row inside that thread — and a system pseudo-user + // speaking in your own thread is not another tenant. // - // Read that precisely (@codex-luna's review): the threadId equality is a - // CALLER invariant, not an authorization check this class performs on an - // arbitrary draft. It holds on the F225 session-handoff path because that - // producer builds the draft from an authenticated InvocationRecord — a request - // body cannot rewrite threadId, userId or the trigger messageId. + // State that precisely (@codex-luna's review): the CALLER invariant is the + // AUTHENTICATED ORIGIN BINDING, not the threadId comparison. This class does + // perform that comparison; what it cannot verify is whether the originRef it + // was handed — threadId, and the ownerUserId compared below — came from an + // authenticated record at all. On the F225 session-handoff path they do: that + // producer derives them from an authenticated InvocationRecord, and a request + // body cannot rewrite them. // // ApprovalIngress serves several producers, so that is a property of the F225 // path and NOT of this class. Any producer deriving a draft from untrusted From e5b6b1c964c15e7d6f51da169b2004cef0185ac0 Mon Sep 17 00:00:00 2001 From: Ragdoll-Opus-5 Date: Wed, 12 Aug 2026 16:12:34 +0300 Subject: [PATCH 5/6] =?UTF-8?q?docs(F167):=20isolation=20is=20a=20conjunct?= =?UTF-8?q?ion=20=E2=80=94=20stop=20crediting=20it=20to=20either=20half=20?= =?UTF-8?q?alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 --- .../domains/approval-hub/ApprovalIngress.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/api/src/domains/approval-hub/ApprovalIngress.ts b/packages/api/src/domains/approval-hub/ApprovalIngress.ts index faa15aaca3..92893aeb07 100644 --- a/packages/api/src/domains/approval-hub/ApprovalIngress.ts +++ b/packages/api/src/domains/approval-hub/ApprovalIngress.ts @@ -219,24 +219,28 @@ export class ApprovalIngress { const origin = await this.deps.messageStore.getById(draft.originRef.messageId); if (!origin || origin.deletedAt || origin._tombstone) throw new Error('Approval origin message not found'); if (origin.threadId !== draft.originRef.threadId) throw new Error('Approval origin message thread mismatch'); - // Cross-tenant isolation rests on the assertion ABOVE: the origin must live in - // the thread the draft names. What remains here is a narrower question — who - // may have authored a row inside that thread — and a system pseudo-user - // speaking in your own thread is not another tenant. + // Cross-tenant isolation here is a CONJUNCTION of two parts, and neither part + // is sufficient on its own (@codex-luna's review, rounds 2-4): // - // State that precisely (@codex-luna's review): the CALLER invariant is the - // AUTHENTICATED ORIGIN BINDING, not the threadId comparison. This class does - // perform that comparison; what it cannot verify is whether the originRef it - // was handed — threadId, and the ownerUserId compared below — came from an - // authenticated record at all. On the F225 session-handoff path they do: that - // producer derives them from an authenticated InvocationRecord, and a request - // body cannot rewrite them. + // (1) CALLER: originRef.threadId / .messageId and ownerUserId must be bound + // to an authenticated record. This class CANNOT verify that binding. + // (2) THIS CLASS: the assertion above checks that the origin message really + // does live in the thread the draft names. // - // ApprovalIngress serves several producers, so that is a property of the F225 + // (2) alone proves nothing about tenancy — a draft naming a thread of its own + // choosing satisfies the comparison trivially. It carries weight only because + // (1) fixes which thread may be named. On the F225 session-handoff path (1) + // holds: that producer derives all three from an authenticated + // InvocationRecord, and a request body cannot rewrite them. + // + // ApprovalIngress serves several producers, so (1) is a property of the F225 // path and NOT of this class. Any producer deriving a draft from untrusted - // input must itself establish and validate an authenticated - // owner/thread/origin binding before calling this ingress; the exemption below - // assumes that binding rather than proving it. + // input must itself establish and validate that binding before calling this + // ingress; the exemption below assumes (1) rather than proving it. + // + // Given (1) AND (2), what the comparison below still decides is narrower: who + // may have authored a row inside an already-verified thread — and a system + // pseudo-user speaking in that thread is not another tenant. // // Without the exemption this rejected precisely the sessions that need it most. // A session-handoff proposal anchors on the message that triggered the From c5d1214278dff8588133d716642c6278210475d3 Mon Sep 17 00:00:00 2001 From: Ragdoll-Opus-5 Date: Wed, 12 Aug 2026 16:50:54 +0300 Subject: [PATCH 6/6] style(F167): satisfy biome formatting in the two guard tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/test/approval-hub/approval-ingress.test.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/api/test/approval-hub/approval-ingress.test.js b/packages/api/test/approval-hub/approval-ingress.test.js index 9dd8ba14fe..1a86460946 100644 --- a/packages/api/test/approval-hub/approval-ingress.test.js +++ b/packages/api/test/approval-hub/approval-ingress.test.js @@ -130,10 +130,7 @@ describe('ApprovalIngress', () => { const harness = makeHarness({ userId: 'user-2', catId: null }); const store = new FakePublicationStore(); - await assert.rejects( - () => harness.ingress.publish(makeDraft(), store), - /Approval origin message owner mismatch/, - ); + await assert.rejects(() => harness.ingress.publish(makeDraft(), store), /Approval origin message owner mismatch/); }); // isSystemUserMessage requires BOTH a system userId AND a system/null catId, so @@ -143,10 +140,7 @@ describe('ApprovalIngress', () => { const harness = makeHarness({ userId: 'scheduler', catId: 'codex-sol' }); const store = new FakePublicationStore(); - await assert.rejects( - () => harness.ingress.publish(makeDraft(), store), - /Approval origin message owner mismatch/, - ); + await assert.rejects(() => harness.ingress.publish(makeDraft(), store), /Approval origin message owner mismatch/); }); it('persists one card, commits its exact envelope, then broadcasts', async () => {