diff --git a/crates/rite-sources/src/iris.rs b/crates/rite-sources/src/iris.rs index b2cd9de..5a4e459 100644 --- a/crates/rite-sources/src/iris.rs +++ b/crates/rite-sources/src/iris.rs @@ -204,6 +204,209 @@ mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + fn assert_inbox_notification_fixture_projection(case: &Value) { + let message = &case["iris_message"]; + let expected = &case["rite_event"]; + let event = IrisSource::parse_message(&message.to_string()) + .unwrap_or_else(|error| panic!("fixture {} parses: {error}", case["name"])); + + assert_eq!(event.source, expected["source"].as_str().unwrap()); + assert_eq!(event.event_type, expected["event_type"].as_str().unwrap()); + assert_eq!( + event.timestamp, + chrono::DateTime::parse_from_rfc3339(expected["timestamp"].as_str().unwrap()) + .unwrap() + .with_timezone(&chrono::Utc) + ); + assert_eq!(event.body, expected["body"].as_str().map(str::to_owned)); + assert_eq!(event.action, expected["action"].as_str().map(str::to_owned)); + assert_eq!( + serde_json::to_value(event.severity).expect("severity serializes"), + expected["severity"] + ); + assert_eq!(event.title, expected["title"].as_str().unwrap()); + assert_eq!(event.metadata["provider"], expected["metadata"]["provider"]); + assert_eq!( + event.metadata["source_id"], + expected["metadata"]["source_id"] + ); + assert_eq!(event.metadata["sender"], expected["metadata"]["sender"]); + assert_eq!(event.metadata["kind"], expected["metadata"]["kind"]); + assert_eq!(event.metadata.get("message"), Some(message)); + assert_eq!( + event.metadata["iris_metadata"], + expected["metadata"]["iris_metadata"] + ); + + for field in [ + "schema_version", + "event_kind", + "installation_id", + "session_id", + "occurrence_id", + "hook_kind", + "direction", + "content_opt_in", + ] { + assert_eq!( + event.metadata["message"]["metadata"][field], + expected["metadata"]["iris_metadata"][field], + "fixture {} preserves the agreed {field} path", + case["name"] + ); + } + } + + fn inbox_notification_fixtures() -> Value { + serde_json::from_str(include_str!( + "../../../tests/fixtures/inbox-notifications/v1/fixtures.json" + )) + .expect("inbox notification fixture JSON parses") + } + + fn named_fixture_case<'a>(cases: &'a [Value], name: &str) -> &'a Value { + cases + .iter() + .find(|case| case["name"] == name) + .unwrap_or_else(|| panic!("fixture {name} exists")) + } + + #[test] + fn inbox_notification_positive_fixtures_preserve_the_agreed_iris_projection() { + let fixtures = inbox_notification_fixtures(); + let positive = fixtures["positive_cases"] + .as_array() + .expect("positive fixture cases are an array"); + assert_eq!( + positive.len(), + 5, + "fixture matrix retains all v1 positive cases" + ); + for case in positive { + assert_inbox_notification_fixture_projection(case); + } + + for event_kind in ["turn_ended", "attention_required", "execution_error"] { + assert!( + positive.iter().any(|case| { + case["iris_message"]["metadata"]["event_kind"] == event_kind + && case["expected_selection"] == true + }), + "the complete matrix covers {event_kind}" + ); + } + let original = positive + .iter() + .find(|case| case["name"] == "turn-ended-inbound") + .expect("original turn-ended case exists"); + let same_body = named_fixture_case( + positive, + "turn-ended-inbound-identical-body-distinct-occurrence", + ); + assert_eq!( + same_body["iris_message"]["body"], + original["iris_message"]["body"] + ); + assert_eq!( + same_body["iris_message"]["metadata"]["session_id"], + original["iris_message"]["metadata"]["session_id"] + ); + assert_ne!( + same_body["iris_message"]["id"], + original["iris_message"]["id"] + ); + assert_ne!( + same_body["iris_message"]["source_id"], + original["iris_message"]["source_id"] + ); + assert_ne!( + same_body["iris_message"]["metadata"]["occurrence_id"], + original["iris_message"]["metadata"]["occurrence_id"] + ); + assert_eq!( + named_fixture_case(positive, "owner-originated-mirror-is-not-an-inbound-alert") + ["expected_selection"] + .as_bool(), + Some(false), + "owner-originated mirrors must not select as inbound alerts" + ); + } + + #[test] + fn inbox_notification_replay_fixture_preserves_identity_at_the_iris_boundary() { + let fixtures = inbox_notification_fixtures(); + let positive = fixtures["positive_cases"] + .as_array() + .expect("positive fixture cases are an array"); + let replay = fixtures["replay_cases"] + .as_array() + .expect("replay fixture cases are an array"); + assert_eq!(replay.len(), 1, "v1 carries one complete exact-replay case"); + for case in replay { + assert_inbox_notification_fixture_projection(case); + } + let original = named_fixture_case(positive, "turn-ended-inbound"); + assert_eq!(replay[0]["expected_selection"].as_bool(), Some(true)); + assert_eq!( + replay[0]["iris_message"], original["iris_message"], + "an exact replay retains the complete original source message" + ); + assert_eq!( + replay[0]["receipt_context"]["prior_receipt"]["iris_message_id"], + original["iris_message"]["id"], + "receipt deduplication keys the prior durable record by stable Iris message ID" + ); + assert_eq!( + replay[0]["receipt_context"]["expected_disposition"], "deduplicated_no_second_receipt", + "the stateful receipt stage, not static selection, prevents the second receipt" + ); + } + + #[test] + fn inbox_notification_negative_fixtures_fail_closed_after_real_source_parsing() { + let fixtures = inbox_notification_fixtures(); + let negative = fixtures["negative_cases"] + .as_array() + .expect("negative fixture cases are an array"); + assert_eq!( + negative.len(), + 3, + "fixture matrix retains all v1 negative cases" + ); + for case in negative { + assert_inbox_notification_fixture_projection(case); + assert_eq!( + case["expected_selection"].as_bool(), + Some(false), + "negative fixture {} must fail policy selection", + case["name"] + ); + } + let missing_installation = named_fixture_case(negative, "missing-installation-id"); + assert!( + missing_installation["iris_message"]["metadata"] + .get("installation_id") + .is_none() + ); + let unsupported = named_fixture_case(negative, "unsupported-stop-failure"); + assert_eq!( + unsupported["iris_message"]["metadata"]["event_kind"], + "stop_failure" + ); + assert!( + unsupported["expected"] + .as_str() + .is_some_and(|expected| expected.contains("do not fabricate execution_error")), + "unknown event kinds must fail closed rather than become execution errors" + ); + let missing_opt_in = named_fixture_case(negative, "missing-content-opt-in"); + assert!( + missing_opt_in["iris_message"]["metadata"] + .get("content_opt_in") + .is_none() + ); + } + #[test] fn preserves_the_full_iris_message_for_matching() { let event = IrisSource::parse_message(r#"{"id":"00000000-0000-0000-0000-000000000001","thread_id":"00000000-0000-0000-0000-000000000002","source":"telegram","source_id":"42","sender":{"source_id":"shiv","display_name":"Shiv"},"kind":"text","body":"URGENT: deploy","timestamp":"2026-08-15T00:00:00Z","metadata":{"chat_id":9}}"#).expect("message parses"); diff --git a/docs/design/inbox-notifications.md b/docs/design/inbox-notifications.md new file mode 100644 index 0000000..7859ca9 --- /dev/null +++ b/docs/design/inbox-notifications.md @@ -0,0 +1,207 @@ +# Inbox-to-notification delivery contract (v1) + +## Status + +This document materializes the COD-498 round-3 agreement as a design and +synthetic-fixture gate. It does not implement a new source, action, route, +configuration value, subscription checkpoint, retry loop, deployment, or +notification. The current Iris SSE source and generic `http_post` action keep +their existing behavior until their separately scoped tickets land. + +The goal is one bounded producer-consumer seam: selected agent reply, +attention, and execution-error occurrences become durable Iris records and may +then be routed by Rite to a static, user-chosen destination. The seam is +explicitly **not** a generic remote-control channel for an agent. + +## Ownership boundary + +| Responsibility | Owner | Constraint | +| --- | --- | --- | +| Source normalization, source installation identity, authenticated ingest, durable inbox history, and committed-event publication | Iris | Source adapters/mappers remain in Iris provider libraries; public operations are Hydra-projected. | +| Local hook receipt and durable pre-ingest spool | Iris `contrib/claude-hooks` bridge (COD-495–497) | Receives local hooks only; never controls sessions, grants permissions, or reads/scrapes transcripts. | +| Selection, static destination policy, durable subscription receipt/checkpoint, per-handler planning/outcomes, and operator recovery | Rite | Sources/actions stay generic; no agent-specific core branch or provider-named action type. | +| Destination acceptance/read semantics | Destination | Rite may observe only its named receiving boundary; it cannot claim a user read receipt. | + +The bridge does not move into Rite. Rite does not install hooks or normalize +agent-specific payloads. Metadata is a selector, never an authorization +boundary; COD-493 owns source authorization and credential policy. + +## V1 envelope and semantic paths + +The committed synthetic matrix lives in +[`tests/fixtures/inbox-notifications/v1/fixtures.json`](../../tests/fixtures/inbox-notifications/v1/fixtures.json). +Every positive case is a pair of one normalized Iris `Message` and the Rite +`RiteEvent` that the existing Iris source produces from it. + +The bridge sets `Message.kind = "text"`; semantic notification kind is never +inferred from `kind`, `Stop`, sender role, or body text. It is carried under +these exact Rite metadata paths: + +| Meaning | Literal Rite path | +| --- | --- | +| schema version | `["metadata", "message", "metadata", "schema_version"]` | +| semantic event kind | `["metadata", "message", "metadata", "event_kind"]` | +| source installation | `["metadata", "message", "metadata", "installation_id"]` | +| session | `["metadata", "message", "metadata", "session_id"]` | +| stable occurrence | `["metadata", "message", "metadata", "occurrence_id"]` | +| hook kind | `["metadata", "message", "metadata", "hook_kind"]` | +| explicit direction | `["metadata", "message", "metadata", "direction"]` | +| explicit content consent | `["metadata", "message", "metadata", "content_opt_in"]` | +| Iris message identity | `["metadata", "message", "id"]` | +| Iris thread identity | `["metadata", "message", "thread_id"]` | +| original source/provenance | `["metadata", "message", "source"]` and `metadata.iris_metadata` compatibility copy | + +`event_kind` is exactly one of `turn_ended`, `attention_required`, or +`execution_error` in v1. Optional `turn_id` and `prompt_id` appear only when +supplied by the source. The occurrence ID and receipt timestamp are fixed at +first bridge receipt and remain unchanged across retries. + +Iris COD-491 owns collision-isolated mapping of +`(source, installation_id, session_id, occurrence_id)` to normalized model +IDs. The fixture mirrors that contract: two installations can reuse a session +ID; two equal text bodies with distinct occurrence IDs are distinct messages. + +### Direction and loop suppression + +`Message.is_outbound` means direction relative to the configured Iris owner. +Bridge-received agent occurrences are inbound (`false`) even when an assistant +wrote their text. The bridge may set `true` only from an explicit +source-authoritative owner-originated fact. The matching `metadata.direction` +value is `inbound` or `owner_originated`; no consumer derives it from sender, +body, destination, or a provider name. + +Loop suppression requires all of these static configured checks before Rite +selects an event: + +1. source allowlist; +2. installation allowlist; +3. `schema_version = 1`; +4. semantic-event-kind allowlist; and +5. the agreed direction/provenance rule; and +6. `content_opt_in = true` before a text-bearing notification is selected or + planned. + +Missing or malformed provenance fails closed for this notification mode. A +`Stop` only means `turn_ended`; it is never evidence that a task succeeded. +Unsupported `StopFailure` must not be fabricated as `execution_error`. + +## Content and destination policy + +Only explicitly opted-in reply/alert text may populate the Iris message body. +The bridge does not include a transcript, tool payload, raw working directory, +credential, destination URL, or permission prompt by default. Fixtures use +synthetic text only. + +Destinations, target identifiers, credential references, and handler +configuration are static local configuration. They must never be interpolated +from a message. No message-derived URL, header, recipient, token, or command +is permitted. Replies to a delivered notification do not control Claude, a +hook bridge, Iris, or Rite in v1. + +The existing generic `http_post` action remains the only contemplated action +shape. COD-500 owns JSON-safe structured action payloads; it must preserve the +existing text-template behavior and cannot introduce a destination-named action +variant. + +## Durable state and delivery guarantees + +The following state labels are deliberately distinct: + +| Label | Meaning | Does not mean | +| --- | --- | --- | +| **Bridge accepted** | Local bridge has durably enqueued the occurrence for ingest. | Iris committed it, Rite received it, or a destination accepted it. | +| **Iris committed** | Iris durably committed the normalized message. | Rite receipt or notification delivery. | +| **Rite received** | Rite atomically persisted subscription identity, stable Iris message ID, and opaque replay checkpoint. | Handler action was planned, sent, accepted, or read. | +| **Handler planned** | One named handler/version has durable pending work. | Any sibling handler or destination received it. | +| **Destination accepted** | The named remote receiving boundary gave a positive acknowledgement. | A human read or acted on it. | +| **Uncertain** | Dispatch might have crossed the destination boundary but a timeout, connection loss, or crash prevents a safe conclusion. | Safe retry. | + +COD-501 stores the receipt/checkpoint and a durable delivery-plan outbox +atomically. Its deduplication key is exactly `(subscription_identity, +iris_message_id)`, not a cursor, timestamp, or body. Before a checkpoint can +advance, the same recoverable transaction (or an equivalently durable outbox +invariant) MUST contain the receipt/dedup evidence, the pinned +handler-configuration version, and pending work for every selected handler. A +receipt/planning storage failure or full pending-work capacity MUST leave the +checkpoint unadvanced and the message available for reconciliation; it MUST NOT +turn a missing plan into a successful receipt. Dedup evidence is retained for +the declared replay/reconciliation horizon; pruning cannot silently make old +sends eligible again. + +COD-502 refines that already-durable plan into independent per-handler +outcomes, retry safety, and recovery; it must not weaken the receipt-to-work +invariant or retroactively replan a checkpointed message against changed +configuration. It keeps secrets out of every durable snapshot and log. +Successful sibling handlers never rerun on ordinary replay. A timeout after +dispatch, connection loss after an unknown send, or crash after send before +durable result is **uncertain**. There is no automatic retry unless +non-dispatch is demonstrated or the configured receiving boundary has an +approved idempotency contract. An operator may requeue only through an explicit +action that acknowledges the risk of duplicate delivery. + +Static policy selection and durable receipt deduplication are separate stages. +An exact replay that still satisfies the configured provenance, event-kind, and +content-consent checks remains **selected** by the future static selector. The +COD-501 receipt transaction then recognizes the previously persisted +`(subscription_identity, iris_message_id)` and records the explicit +`deduplicated_no_second_receipt` disposition. A selector alone does not claim +to recognize or suppress a replay. + +## Recovery and degraded subscriptions + +Public replay is a prerequisite, not a result of the private Iris broker or +COD-485 negotiation. An invalid/expired replay cursor or server restart moves +a durable subscription to visible **degraded** state. Already-durable work is +preserved; no hidden future-only reset occurs. + +V1 does not promise lossless history catch-up. The operator reconciles or +resets a degraded subscription through an approved generated status/recovery +operation, leaving an explicit unresolved-gap record. A durable backfill can +only use the approved Iris reader/poll contract from COD-491/COD-492; it must +not scrape private snapshots, repurpose COD-463 timestamp polling, or treat +COD-475 outbound history as the inbox reader. + +Finite capacity/retention values, credential/reference storage policy, and +operator authorization are intentional design/security gates. They are not +filled in by a runtime default, fixture, or runner assumption. + +## Dependency-ordered delivery slices + +1. **COD-491** — finalize durable inbox identity, reader, ordering, and recovery + contract; **COD-492** adds generated reads; **COD-493** adds source-scoped + authorization; **COD-494** publishes committed events. +2. **COD-495–497** — map local hook events to the agreed envelope, spool safely, + package/install it, and run a public-boundary acceptance path. +3. **COD-499** — add explicit nested predicates so handlers can select the + literal metadata paths above without changing scalar/compound semantics. +4. **COD-500** — add JSON-safe generic action bodies while preserving existing + generic HTTP behavior. +5. **COD-501** — atomically add durable subscription receipt/checkpoint state + and the configuration-pinned pending-work outbox; storage failure or full + capacity cannot advance the checkpoint. +6. **COD-502** — add per-handler outcomes, uncertainty, safe retry, and + recovery on the already-durable receipt-to-work invariant. +7. **COD-503** — exercise the fully composed cross-service fixture path and + publish setup/recovery documentation. + +No slice may claim end-to-end exactly-once delivery, live deployment, or human +read behavior without the corresponding durable contract and executable +verification. + +## Fixture acceptance matrix + +The v1 fixture set provides complete paired Iris-message/Rite-event records +for `turn_ended`, `attention_required`, and `execution_error`; an exact replay +of a stable message identity; same-body distinct occurrences; two installations +sharing a session; quoted/multiline opted-in text; and a mirrored +owner-originated event. Complete negative inputs cover missing installation, +unsupported `stop_failure`, and missing content opt-in. They remain parseable +at the source boundary but are marked to fail policy selection. + +Tests parse every positive, replay, and negative Iris message through the real +`IrisSource::parse_message` boundary and assert the literal metadata paths +above, including the consent path, and compare every declared Rite event field. +The replay fixture distinguishes its static selection result from its separate +stateful receipt disposition. Future selection tests must ensure negative cases +never reach a configured handler. They must not substitute a mocked nested JSON +shape for the source adapter. diff --git a/tests/fixtures/inbox-notifications/v1/README.md b/tests/fixtures/inbox-notifications/v1/README.md new file mode 100644 index 0000000..4e0c67c --- /dev/null +++ b/tests/fixtures/inbox-notifications/v1/README.md @@ -0,0 +1,23 @@ +# Inbox notification fixtures v1 + +These synthetic fixtures are the producer-consumer contract agreed in COD-498. +They contain no real message, transcript, endpoint, token, user identifier, or +working-directory data. + +Each positive case pairs an Iris `Message` with the selected public fields of +the `RiteEvent` shape produced by `rite-sources::iris::IrisSource::parse_message`. +The adapter's retained `metadata.message` must equal the paired Iris message; +the fixture omits that mechanically duplicated subtree. Tests must parse the +Iris message through that real adapter, then compare semantic values at the +literal paths in `docs/design/inbox-notifications.md`. + +`expected_selection` is policy guidance for the future selector: it is not a +claim that the current flat matcher can traverse the paths yet. Negative cases +must fail selection before any handler action is planned. + +`expected_selection` describes only static policy eligibility. An exact replay +can remain statically eligible while its `receipt_context` separately records +the stateful COD-501 result: the already-persisted +`(subscription_identity, iris_message_id)` must produce +`deduplicated_no_second_receipt`. The selector itself does not claim to dedupe +replays. diff --git a/tests/fixtures/inbox-notifications/v1/fixtures.json b/tests/fixtures/inbox-notifications/v1/fixtures.json new file mode 100644 index 0000000..ea65ea2 --- /dev/null +++ b/tests/fixtures/inbox-notifications/v1/fixtures.json @@ -0,0 +1,548 @@ +{ + "contract": "inbox-notifications/v1", + "schema_version": 1, + "positive_cases": [ + { + "name": "turn-ended-inbound", + "expected_selection": true, + "iris_message": { + "id": "11111111-1111-5111-8111-111111111111", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/dHVybi1lbmRlZC1hbHBoYQ", + "sender": { + "id": "33333333-3333-5333-8333-333333333333", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic task turn completed.", + "attachments": [], + "timestamp": "2026-09-14T12:00:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:00:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic task turn completed.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/dHVybi1lbmRlZC1hbHBoYQ", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + } + } + }, + { + "name": "attention-required-multiline", + "expected_selection": true, + "iris_message": { + "id": "44444444-4444-5444-8444-444444444444", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/YXR0ZW50aW9uLWFscGhh", + "sender": { + "id": "33333333-3333-5333-8333-333333333333", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic attention required:\n\"choose a safe option\"", + "attachments": [], + "timestamp": "2026-09-14T12:01:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-002", + "hook_kind": "attention_required", + "event_kind": "attention_required", + "direction": "inbound", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:01:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic attention required:\n\"choose a safe option\"", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/YXR0ZW50aW9uLWFscGhh", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-002", + "hook_kind": "attention_required", + "event_kind": "attention_required", + "direction": "inbound", + "content_opt_in": true + } + } + } + }, + { + "name": "execution-error-second-installation-same-session", + "expected_selection": true, + "iris_message": { + "id": "55555555-5555-5555-8555-555555555555", + "thread_id": "66666666-6666-5666-8666-666666666666", + "source": "agent_hook", + "source_id": "inbox/v1/ZXJyb3ItYmV0YQ", + "sender": { + "id": "77777777-7777-5777-8777-777777777777", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWJldGE", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic execution error.", + "attachments": [], + "timestamp": "2026-09-14T12:00:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-beta", + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "execution_error", + "event_kind": "execution_error", + "direction": "inbound", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:00:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic execution error.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/ZXJyb3ItYmV0YQ", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-beta", + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "execution_error", + "event_kind": "execution_error", + "direction": "inbound", + "content_opt_in": true + } + } + } + }, + { + "name": "owner-originated-mirror-is-not-an-inbound-alert", + "expected_selection": false, + "iris_message": { + "id": "88888888-8888-5888-8888-888888888888", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/bWlycm9yLWFscGhh", + "sender": { + "id": "99999999-9999-5999-8999-999999999999", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "iris-owner", + "display_name": "Iris owner", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic mirrored owner message.", + "attachments": [], + "timestamp": "2026-09-14T12:02:00Z", + "is_outbound": true, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-003", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "owner_originated", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:02:00Z", + "severity": "info", + "title": "Iris owner", + "body": "Synthetic mirrored owner message.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/bWlycm9yLWFscGhh", + "sender": "iris-owner", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-003", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "owner_originated", + "content_opt_in": true + } + } + } + }, + { + "name": "turn-ended-inbound-identical-body-distinct-occurrence", + "expected_selection": true, + "iris_message": { + "id": "aaaaaaaa-aaaa-5aaa-8aaa-aaaaaaaaaaaa", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/c2FtZS1ib2R5LW9jYy0wMDQ", + "sender": { + "id": "33333333-3333-5333-8333-333333333333", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic task turn completed.", + "attachments": [], + "timestamp": "2026-09-14T12:03:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-004", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:03:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic task turn completed.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/c2FtZS1ib2R5LW9jYy0wMDQ", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-004", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + } + } + } + ], + "negative_cases": [ + { + "name": "missing-installation-id", + "expected_selection": false, + "iris_message": { + "id": "bbbbbbbb-bbbb-5bbb-8bbb-bbbbbbbbbbbb", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/bWlzc2luZy1pbnN0YWxsYXRpb24", + "sender": { + "id": "33333333-3333-5333-8333-333333333333", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic task turn completed.", + "attachments": [], + "timestamp": "2026-09-14T12:00:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:00:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic task turn completed.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/bWlzc2luZy1pbnN0YWxsYXRpb24", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + } + }, + "expected": "fail closed before handler selection" + }, + { + "name": "unsupported-stop-failure", + "expected_selection": false, + "iris_message": { + "id": "cccccccc-cccc-5ccc-8ccc-cccccccccccc", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/c3RvcC1mYWlsdXJl", + "sender": { + "id": "33333333-3333-5333-8333-333333333333", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic task turn completed.", + "attachments": [], + "timestamp": "2026-09-14T12:00:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-stop-failure", + "hook_kind": "stop_failure", + "event_kind": "stop_failure", + "direction": "inbound", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:00:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic task turn completed.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/c3RvcC1mYWlsdXJl", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-stop-failure", + "hook_kind": "stop_failure", + "event_kind": "stop_failure", + "direction": "inbound", + "content_opt_in": true + } + } + }, + "expected": "do not fabricate execution_error; fail selection" + }, + { + "name": "missing-content-opt-in", + "expected_selection": false, + "iris_message": { + "id": "dddddddd-dddd-5ddd-8ddd-dddddddddddd", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/bWlzc2luZy1vcHQtaW4", + "sender": { + "id": "33333333-3333-5333-8333-333333333333", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic task turn completed.", + "attachments": [], + "timestamp": "2026-09-14T12:00:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-no-opt-in", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound" + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:00:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic task turn completed.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/bWlzc2luZy1vcHQtaW4", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-no-opt-in", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound" + } + } + }, + "expected": "do not forward message body by default; fail selection" + } + ], + "replay_cases": [ + { + "name": "turn-ended-exact-replay", + "expected_selection": true, + "receipt_context": { + "prior_receipt": { + "subscription_identity": "synthetic-inbox-notifications/v1", + "iris_message_id": "11111111-1111-5111-8111-111111111111" + }, + "expected_disposition": "deduplicated_no_second_receipt" + }, + "iris_message": { + "id": "11111111-1111-5111-8111-111111111111", + "thread_id": "22222222-2222-5222-8222-222222222222", + "source": "agent_hook", + "source_id": "inbox/v1/dHVybi1lbmRlZC1hbHBoYQ", + "sender": { + "id": "33333333-3333-5333-8333-333333333333", + "source": "agent_hook", + "provider_instance": "ingest/v1/c3ludGhldGljLWFscGhh", + "source_id": "agent-build", + "display_name": "Build agent", + "avatar_url": null, + "metadata": {} + }, + "kind": "text", + "body": "Synthetic task turn completed.", + "attachments": [], + "timestamp": "2026-09-14T12:00:00Z", + "is_outbound": false, + "metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + }, + "rite_event": { + "source": "iris", + "event_type": "text", + "action": null, + "timestamp": "2026-09-14T12:00:00Z", + "severity": "info", + "title": "Build agent", + "body": "Synthetic task turn completed.", + "metadata": { + "provider": "agent_hook", + "source_id": "inbox/v1/dHVybi1lbmRlZC1hbHBoYQ", + "sender": "agent-build", + "kind": "text", + "iris_metadata": { + "schema_version": 1, + "installation_id": "synthetic-alpha", + "session_id": "session-7", + "occurrence_id": "occurrence-001", + "hook_kind": "turn_ended", + "event_kind": "turn_ended", + "direction": "inbound", + "content_opt_in": true + } + } + }, + "expected": "same Iris message ID and no second Rite durable receipt" + } + ] +}