diff --git a/openspec/changes/add-outbound-history/design.md b/openspec/changes/add-outbound-history/design.md new file mode 100644 index 0000000..2e12ea3 --- /dev/null +++ b/openspec/changes/add-outbound-history/design.md @@ -0,0 +1,241 @@ +# Design: Outbound History Capture + +## Status and frozen-artifact rule + +This document is a specification gate. It changes no runtime behavior, +generated artifact, public route, CLI command, MCP tool, storage state, or +provider implementation. The four files in this directory are the only +intended diff for the COD-475 specification slice. + +The following artifacts remain frozen and authoritative for their own scope: + +- `add-ingested-inbox/*` for the existing ingest foundation and its recorded + identity/atomicity guarantees; +- `add-retained-history/*` from COD-544 for the first store-scoped retained + reader and cross-process page lifecycle; +- `add-forward-poll-cursors/*` for provider forward-poll security; +- `add-sse-replay-cursors/*` for bounded live replay. + +This successor adds the producer-side outbound boundary. It does not resolve a +conflict in those documents, rewrite them, or authorize runtime implementation +before the relevant implementation tickets are reviewed. + +## Product and architecture decision + +Iris has three deliberately separate paths: + +1. **External send path.** A configured `MessageProvider` performs an external + operation and returns a source-normalized result or a typed failure. +2. **Durable producer capture.** The boundary that owns the send operation + converts accepted source results into retained normalized records and + commits them through the same durable writer/record seam used by inbound + producers. +3. **Retained query path.** COD-492 answers history questions from the durable + backend through the COD-544 reader contract, without asking providers to + acquire or synchronize data as a query side effect. + +The producer capture seam is not a `MessageProvider`, cannot send, and cannot +be implemented as a hidden query. Storage owns atomicity and identity indexes; +provider adapters own source-specific normalization; a shared transport-neutral +producer helper is used by HTTP, compiled CLI, and MCP so those surfaces do not +invent different capture semantics. + +## Ownership and dependency matrix + +| Concern | Owner | This contract | +| --- | --- | --- | +| External provider request and source response normalization | Provider implementation | Consumes the exact returned result; does not write an HTTP-owned archive. | +| Accepted outbound capture and send outcome | COD-475 | Defines the producer record, idempotency, accepted-prefix, audit/persistence, and ambiguity semantics. | +| Durable retained record/index and atomic commit | COD-492 storage slice, using COD-544 seam | Must commit the producer record with the same old-or-new visibility boundary as other retained records. | +| Retained pages, coverage, snapshot tokens, zero-provider-call reads | COD-492 / COD-544 | Reads COD-475 records; never decides whether a send was accepted or retries one. | +| Source-scoped ingest authorization and registration | COD-493 | Validates producer scope before the writer; not redefined here. | +| Durable commit publication/live replay | COD-494 | May publish a committed record after it is visible; page tokens are not SSE cursors. | +| Encryption at rest | COD-543 | Non-blocking design follow-up; this contract claims no ciphertext. | + +COD-475 runtime capture must consume a reviewed, compatible retained-record +seam. The preferred predecessor is the reviewed COD-544 contract, but this is +an interface safety requirement rather than a new workflow hold: if COD-475 +must proceed before that PR lands, its implementation issue must carry an +explicitly reviewed equivalent seam and compatibility decision. COD-544's +reader remains independently owned; this statement is a producer/reader +interface boundary, not a request to duplicate the reader. + +## Accepted outbound record + +The future implementation introduces one typed, transport-neutral capture +value (the exact Rust name may be selected in the implementation PR) with the +following required content: + +- the exact normalized `Message` returned by the provider, with + `is_outbound=true` only when the source supplies an owner-authoritative + direction fact; +- the owning normalized `Thread` and any installation-global `Contact` values + needed for retained ownership and participant indexes; +- the configured provider instance and source/provenance scope, preserved in + the agreed normalized fields/root provenance metadata rather than inferred + from the provider type; +- stable source identity for the accepted occurrence, including the provider + message ID when available, and a producer replay/reconciliation key; +- the exact provider timestamp and body; no substitution with local arrival + time or a request timestamp; +- existing attachment references and an explicit content-coverage result; + `iris://attachment/{uuid}` is complete only when the configured + `AttachmentStore` owns the referenced bytes and metadata; +- the canonical payload/hash used to distinguish a matching retry from a + conflicting reuse of an identity. + +The record is a producer input to the durable writer. It is not a new public +`OutboundMessage` resource, an audit-only JSON blob, or a provider-specific +history table. Where the current normalized model has no dedicated field for +configured instance identity, the implementation must use the approved +provider-instance/provenance representation from the retained-record contract; +it must not overload `source` or concatenate unescaped IDs. + +### Identity and reconciliation + +For a configured instance `I`, thread source identity `T`, and accepted +provider occurrence `M`, the producer's canonical identity is conceptually: + +```text +(source, I, T, M) +``` + +The implementation uses the fixed, versioned, length-prefixed identity scheme +owned by the retained-record contract. `source`, `I`, `T`, and `M` are opaque +fields; delimiter concatenation, message body, display name, sender role, and +arrival order are not identity inputs. + +A source echo, retry, or duplicate producer notification with the same scoped +identity and equivalent immutable payload is an idempotent no-op. A same-key +record with a different immutable payload is a typed conflict with no partial +mutation. Two equal-text messages with different occurrences remain separate. +Two same-type configured instances with colliding local thread/message IDs +remain separate. The producer must not silently fall back to an unqualified +provider-type identity when a configured instance is known. + +If a provider cannot supply a stable accepted occurrence identity, the producer +may not claim durable idempotency for that result. It must surface that boundary +as an explicit capture/reconciliation outcome and leave any required recovery to +a future source-specific contract; it must not guess from body or timestamp. + +## Send and capture outcomes + +The existing successful `send_message` value remains the normalized first +accepted `Message` unless a separately reviewed generated contract changes it. +Capture status is an independent fact and is never hidden by returning a +plausible success. Future generated HTTP/CLI/MCP projections must expose the +same structured outcome/error codes; a transport may format diagnostics but may +not change the outcome. + +The implementation must represent these cases distinctly: + +| External operation | Durable capture/audit | Required result semantics | +| --- | --- | --- | +| Rejected before acceptance | No record | Return the original provider rejection; no outbound history record or retry identity is fabricated. | +| One or more accepted | All accepted records committed | Return the accepted result. The next retained snapshot returns exact message/thread/contact values and updates `last_message_at`. | +| Accepted, then durable capture fails | No complete durable commit for the failed capture | Return `outbound_capture_failed` with sanitized accepted identity/reconciliation information and `automatic_retry=false`. Never claim the provider rejected the send and never resend automatically. | +| Accepted, then audit write fails | Capture commit outcome is explicit | Return or attach `outbound_audit_failed` according to the reviewed public error projection. The accepted external result remains accepted; an audit failure never authorizes a resend or masks the provider outcome as a rejection. | +| Provider outcome ambiguous | No fabricated acceptance/rejection | Return `outbound_ambiguous` with the source-supported reconciliation key when available and `automatic_retry=false`. A later source-authoritative reconciliation may capture the result. | +| Accepted prefix, later request fails | Prefix records are captured independently or in one atomic prefix commit | Return a typed `outbound_partial_failure` containing the accepted prefix identities and the later provider failure. The prefix is never rolled back merely because request N+1 failed, and request N+1 is not invented. | +| Matching replay/echo | Already committed | Return idempotent success/no-op. Do not mint a second message, thread timestamp, audit event, or publication record. | +| Identity/hash conflict | No new mutation | Return a typed conflict before exposing a duplicate; never last-write-wins. | + +`automatic_retry=false` is a policy boundary for the producer. A caller may +perform a separately designed reconciliation action when the source supports +it; this contract does not add a retry endpoint or a new credential path. +Error messages and logs must not contain provider secrets, authorization +headers, or raw configuration. Message bodies may remain in the caller-visible +normalized result where the existing contract already returns them, but they +must not be copied into diagnostics or audit summaries unnecessarily. + +## Multi-request sends and accepted prefixes + +Some providers implement one logical send with multiple external requests, +such as one text-plus-attachment operation. The provider boundary must expose +enough typed information for the producer to distinguish: + +- requests that were rejected before acceptance; +- each accepted message/attachment result and its stable source identity; and +- a request whose external result is ambiguous. + +The public operation may continue returning its existing first-message shape, +but the internal capture result must not discard an accepted prefix. Each +accepted prefix entry is normalized and captured with the same instance, +thread, timestamp, attachment, idempotency, and failure rules as a single +send. If capturing an accepted prefix itself fails, the caller receives the +explicit post-acceptance failure and the exact committed/uncommitted boundary; +there is no implicit rollback or resend. + +## Thread, contact, direction, and attachments + +A captured outbound message must carry the owning thread and update that +thread's retained `last_message_at` to the maximum accepted message timestamp. +An exact timestamp tie is resolved by the retained contract's stable message +identity, never by arrival order. Participant/contact upserts are part of the +same durable visibility boundary when required for the reader to resolve the +thread. A partial or failed capture must not expose a thread timestamp without +its corresponding message record. + +`is_outbound` is source-authoritative. The implementation must not infer it +from an assistant name, message body, destination, or the fact that the caller +used `send_message`. Existing attachment references are preserved exactly. +Stored attachment bytes and metadata must be durable before a record claims +complete attachment coverage; provider URLs remain reference-only. A failed +attachment capture is an explicit capture failure, not a reason to resend the +external message. + +## Public boundary and compatibility + +No source-specific route, provider-named operation, or second archive is added. +The existing generated `send_message` operation remains the only send entry +point. Any new structured errors or result metadata are declared once in +`api/operations.yaml` and projected through Hydra to HTTP, compiled CLI, and +MCP. The three surfaces call one shared producer capture helper after provider +dispatch; they do not each implement their own `apply_batch` shape. + +The retained reader consumes only committed records. A subsequent +`list_messages_v2`/`list_threads_v2` page from COD-492 may return a captured +outbound result with zero messaging-provider calls. Existing provider-only v1 +list operations are not silently changed by this specification; compatibility +or versioned reader adoption belongs to COD-544/COD-492. + +## Synthetic acceptance matrix + +All rows use deterministic synthetic providers and no real credentials. The +provider-call count is measured while reading retained history, not while the +original send is performed. + +| Scenario | Setup | Required result | +| --- | --- | --- | +| Single accepted text | Instance `telegram.ops`, thread `chat-7`, source message `m-1` is accepted and capture succeeds. | Success returns the exact normalized outbound message; a later retained page returns the same ID/body/timestamp with `is_outbound=true`; thread `last_message_at` equals the message timestamp. | +| Same-type instance collision | `telegram.ops-a/chat-7/m-1` and `telegram.ops-b/chat-7/m-1` are both accepted. | Two distinct configured-instance-qualified records; neither overwrites the other; ordering includes instance identity. | +| Equal body, distinct occurrences | `m-1` and `m-2` have the same body and timestamp but distinct source identities. | Two messages remain; body equality is not deduplication. | +| Source echo/retry | The same instance/thread/source occurrence and equivalent canonical payload is captured twice. | One durable message, one thread timestamp update, one audit/publication record; second capture is idempotent. | +| Identity conflict | Same identity is captured with a different immutable body or timestamp. | Typed conflict; no message, thread, audit, or publication mutation from the second attempt. | +| Accepted prefix | Attachment request 1 is accepted; request 2 is rejected. | `outbound_partial_failure` names accepted prefix identities and the later failure; accepted request 1 is retained; request 2 is absent. | +| Capture failure after acceptance | Provider accepts `m-1`, storage returns an error before commit. | `outbound_capture_failed`, accepted identity is explicit, `automatic_retry=false`, no false rejection and no automatic resend. | +| Audit failure after acceptance | Capture commits `m-1`, audit append fails. | Accepted message remains durable; `outbound_audit_failed` is surfaced according to the generated error contract; no resend. | +| Ambiguous provider result | Provider cannot establish whether request `m-1` was accepted. | `outbound_ambiguous`, reconciliation key only when source-supported, no fabricated durable success and no automatic retry. | +| Attachment coverage | Accepted message references a durable `iris://attachment/{uuid}` and a provider URL. | Reader returns both exact references; coverage is complete only for the durable reference and reference-only for the provider URL. No attachment download occurs during the query. | +| Independent reader | Send process exits after a successful capture; another process shares the configured durable backend. | The reader returns the outbound record from the retained snapshot with zero provider calls. | +| Mutation during paging | A retained page is opened, then another accepted send commits. | The existing page cursor remains on its original snapshot; a new snapshot sees the new message. No timestamp-only shortcut or provider fallback occurs. | + +These are specification fixtures, not claims that the runtime already passes. +The implementation must execute the feasible rows through the generated +boundaries and record exact commands/results in its PR. + +## Implementation and migration gates + +- T1–T2 are contract review gates. No runtime work begins from this + specification PR alone. +- Runtime capture depends on the reviewed COD-544 retained-record seam and its + compatibility decisions. COD-492 remains the reader owner; do not duplicate + it in a producer PR. +- A storage-schema migration, identity-version change, source authorization + change, or public send error shape requires its own named reviewed task and + rollback/compatibility evidence. This contract authorizes none implicitly. +- Each implementation PR runs the complete Iris gates and a generated + HTTP/compiled-CLI/MCP acceptance path; private provider tests alone are not + sufficient. +- No release, deployment, credential, consumer repin, or live-provider result + follows from these artifacts. diff --git a/openspec/changes/add-outbound-history/proposal.md b/openspec/changes/add-outbound-history/proposal.md new file mode 100644 index 0000000..a921dc0 --- /dev/null +++ b/openspec/changes/add-outbound-history/proposal.md @@ -0,0 +1,100 @@ +# Proposal: Outbound History Capture + +## Problem + +Iris can return a normalized message from `send_message`, but the current +provider-local send paths do not make that accepted outbound message part of +Iris-managed durable history. A subsequent message query can therefore omit a +message that the provider accepted, and a thread's retained `last_message_at` +can remain stale. Keeping the result only in a provider cache or audit log does +not satisfy the settled storage-first history direction. + +The September 28 product ruling requires Iris to persist known accepted inbound +and outbound history to the configured durable backend (or Iris's default) and +answer retained-history queries without messaging-provider I/O. COD-475 owns +the producer-side capture of accepted outbound results and send outcomes; the +shared retained reader is owned by COD-492 and specified by the separate +`add-retained-history` successor. + +## Proposal + +Define a source-neutral producer contract for accepted outbound capture before +runtime work: + +1. A successful external send produces an exact normalized outbound record, + including the provider instance, source identity, thread, direction, + timestamp, body, and supported attachment references. Capture uses the same + durable writer and retained-record seam as inbound ingestion; it does not + create an HTTP-owned archive or a second provider cache. +2. The producer supplies stable configured-instance-qualified identity and + replay material. A source echo or retry reconciles to the same message; + equal bodies in distinct occurrences remain distinct. Identity never comes + from message text, assistant authorship, arrival order, or an unqualified + local source ID. +3. A multi-request send preserves every externally accepted prefix even when a + later request fails. The public send result and the durable capture outcome + remain separate facts, so a persistence or audit failure never becomes an + automatic resend and never changes an accepted send into a rejection. +4. Ambiguous provider outcomes are explicit. Iris does not fabricate success, + silently drop the possibility of an accepted message, or retry automatically + without a source-authoritative reconciliation path. +5. The retained reader consumes the committed record later through its normal + generated surfaces. It owns query pagination, coverage, and zero-upstream- + I/O behavior; COD-475 does not reimplement a reader or publish a source- + specific route. + +## Scope + +In scope: + +- this OpenSpec contract and its ownership/dependency boundaries; +- normalized outbound capture inputs and configured-instance identity; +- accepted-prefix, ambiguity, idempotency, attachment, audit, and persistence + failure semantics; +- generated-boundary error/result requirements and a synthetic acceptance + matrix; +- dependency-ordered future implementation tasks for core, storage, provider + adapters, and HTTP/CLI/MCP wrappers. + +Out of scope for this specification PR: + +- Rust/runtime, generated artifact, storage-schema, or migration changes; +- the shared retained reader and durable snapshot/page implementation (COD-492); +- ingest authorization and cross-scope admission (COD-493); +- committed-event publication/live replay (COD-494); +- encryption-at-rest/key custody (COD-543); +- provider backfill/acquisition, deployment, credentials, release, or live + provider validation. + +## Non-goals + +- Do not make a retained-history record sendable through a guessed provider. +- Do not turn provider-local caches, audit files, or the bounded SSE broker into + the history authority. +- Do not reinterpret the existing `send_message` success `Message` as proof + that durable capture succeeded unless the capture result says so. +- Do not resend automatically after an accepted external operation, a durable + capture failure, an audit failure, or an ambiguous provider result. +- Do not silently rewrite frozen `add-ingested-inbox`, + `add-forward-poll-cursors`, `add-sse-replay-cursors`, or + `add-retained-history` artifacts. + +## Dependencies and ownership + +The producer contract consumes the reviewed retained-record seam from +`add-retained-history`/COD-544 and is later implemented by COD-475. COD-492 +owns the reader and generated retained pages. COD-493 owns source-scoped +authorization and registration; COD-494 owns committed publication; COD-543 +is non-blocking encryption design. None of those tickets may be reimplemented +or silently amended here. + +## Success criteria + +A reviewer can determine, for every send path and failure case: + +- which records are committed, with which configured-instance-qualified IDs; +- how an accepted prefix is reported when a later request fails; +- how persistence/audit failure and ambiguous provider outcomes are surfaced; +- why no retry can duplicate an accepted message; and +- how the later retained reader will expose the committed record through the + same generated HTTP, compiled CLI, and MCP query contract as inbound history. diff --git a/openspec/changes/add-outbound-history/specs/outbound-history/spec.md b/openspec/changes/add-outbound-history/specs/outbound-history/spec.md new file mode 100644 index 0000000..5e32341 --- /dev/null +++ b/openspec/changes/add-outbound-history/specs/outbound-history/spec.md @@ -0,0 +1,224 @@ +# Outbound History Capture Specification + +## ADDED Requirements + +### Requirement: Accepted outbound results use the shared retained-record seam + +For every supported send operation that the external provider accepts, Iris +SHALL make the exact normalized outbound result available to the shared durable +retained-record writer. The record SHALL include the normalized message, +configured provider instance, source/provenance identity, owning thread, the +required contact/participant values, source-authoritative direction, provider +timestamp, body, and supported attachment references. + +The producer SHALL use the same durable record/visibility boundary consumed by +the retained reader. It SHALL NOT write a provider-local history cache, an +HTTP-handler-only archive, or an audit-only substitute. A retained record SHALL +not become sendable merely because it was captured. + +#### Scenario: Accepted text is retained + +- **GIVEN** configured instance `telegram.ops` accepts message `m-1` for thread + `chat-7` +- **WHEN** the producer captures the normalized result successfully +- **THEN** the send operation returns the exact normalized message +- **AND** a later retained query returns the same body, source identity, + timestamp, and `is_outbound=true` +- **AND** the retained thread's `last_message_at` is updated at the same + durable visibility boundary + +#### Scenario: Capture does not create a send-capable source + +- **GIVEN** a retained outbound record exists without an available upstream + provider +- **WHEN** the retained reader returns it +- **THEN** it is readable with its provenance and coverage +- **AND** the reader does not call a provider to acquire it +- **AND** it is not routed to `send_message` through a guessed provider + +### Requirement: Outbound identity is configured-instance-qualified and replay-safe + +The producer SHALL derive the accepted outbound identity from the approved +versioned retained-record tuple containing source, configured provider +instance, thread/source identity, and provider occurrence identity. The +implementation SHALL preserve stable provider/source IDs and SHALL NOT derive +identity from message body, display name, assistant authorship, local arrival +order, or an unqualified provider type. + +A matching source echo, retry, or capture replay with equivalent immutable +payload SHALL be an idempotent no-op. Reusing the identity with a different +immutable payload SHALL fail with a typed conflict and SHALL NOT mutate the +retained message, thread timestamp, audit record, or publication record. + +#### Scenario: Same-type instances remain isolated + +- **GIVEN** `telegram.ops-a/chat-7/m-1` and `telegram.ops-b/chat-7/m-1` are + accepted +- **WHEN** both results are captured +- **THEN** two configured-instance-qualified messages are retained +- **AND** neither overwrites, aliases, or deduplicates the other + +#### Scenario: Equal bodies do not deduplicate distinct occurrences + +- **GIVEN** two accepted occurrences have the same body and timestamp but + distinct source occurrence identities +- **WHEN** the producer captures both +- **THEN** both messages remain visible with distinct stable IDs +- **AND** body equality is not treated as a replay + +#### Scenario: Conflicting replay writes nothing + +- **GIVEN** identity `m-1` is already committed with body `first` +- **WHEN** a later capture reuses `m-1` with body `different` +- **THEN** the producer returns a typed identity/payload conflict +- **AND** no second message, timestamp, audit record, or publication record is + committed + +### Requirement: Accepted prefixes survive later request failure + +When one logical send performs multiple external requests, the producer SHALL +retain every request that the provider has positively accepted, even if a later +request fails. The provider/producer boundary SHALL distinguish rejected, +accepted, and ambiguous request outcomes. The producer SHALL not fabricate an +accepted result for a failed or ambiguous request. + +The public send projection SHALL preserve the existing successful first-message +shape unless a separately reviewed generated contract changes it. A partial +failure SHALL expose a structured `outbound_partial_failure` outcome/error with +the accepted-prefix identities and the later failure; it SHALL not silently +rollback accepted messages or automatically retry them. + +#### Scenario: Accepted attachment prefix + +- **GIVEN** attachment request 1 is accepted and request 2 is rejected +- **WHEN** the logical send completes with a later failure +- **THEN** request 1 is durably captured with its exact reference and identity +- **AND** the result identifies the accepted prefix and later provider failure +- **AND** request 2 is absent from retained history +- **AND** no automatic resend occurs + +#### Scenario: Ambiguous later request + +- **GIVEN** request 1 is accepted and request 2 has an ambiguous external + outcome +- **WHEN** the producer reports the logical send +- **THEN** request 1 remains an accepted retained record +- **AND** the result is `outbound_ambiguous` for request 2 with only a + source-supported reconciliation key +- **AND** the producer does not claim request 2 succeeded or failed +- **AND** automatic retry is false + +### Requirement: Post-acceptance failures preserve external truth + +If a provider accepts a message but durable capture fails, Iris SHALL report a +structured `outbound_capture_failed` outcome that distinguishes external +acceptance from durable visibility. It SHALL include only sanitized stable +identity/reconciliation information needed for recovery, SHALL not claim the +provider rejected the message, and SHALL not automatically resend. + +If durable capture succeeds but a required audit write fails, Iris SHALL report +or attach a structured `outbound_audit_failed` outcome according to the reviewed +generated error contract. The accepted message remains accepted and durable; +an audit failure SHALL not mask it as a provider rejection or authorize a +resend. Diagnostics SHALL exclude credentials, authorization headers, and raw +configuration. + +#### Scenario: Capture failure after external acceptance + +- **GIVEN** the provider accepted `m-1` +- **AND** the durable writer fails before committing its record +- **WHEN** the send boundary returns +- **THEN** it returns `outbound_capture_failed` +- **AND** it marks automatic retry false and identifies the accepted external + result without claiming retained visibility +- **AND** a retained query does not fabricate `m-1` + +#### Scenario: Audit failure after durable capture + +- **GIVEN** the provider accepted `m-1` +- **AND** its retained record committed +- **AND** the audit append fails +- **WHEN** the send boundary returns +- **THEN** the message remains readable from the durable retained store +- **AND** the caller receives the agreed `outbound_audit_failed` signal +- **AND** the provider result is not rewritten as a rejection or resent + +### Requirement: Direction, thread time, contacts, and attachments remain exact + +The producer SHALL set `Message.is_outbound` only from a source-authoritative +owner-originated fact. It SHALL not infer direction from caller role, body, +destination, assistant authorship, or the use of a send command. The captured +thread SHALL update `last_message_at` to the maximum accepted provider +message timestamp, resolving an exact tie by the retained stable message +identity rather than arrival order. Required thread/contact updates and the +message SHALL share one durable visibility boundary. + +The producer SHALL preserve existing attachment references. A stored +`iris://attachment/{uuid}` reference SHALL claim complete content coverage +only when the configured durable attachment store owns its bytes and metadata; +a provider URL is reference-only. Capture or attachment persistence failure is +an explicit post-acceptance outcome, not a reason to resend the external +message. + +#### Scenario: Attachment coverage is honest + +- **GIVEN** an accepted message has one durable Iris attachment reference and + one provider URL +- **WHEN** a retained page returns the message +- **THEN** both exact references are preserved +- **AND** coverage marks the durable reference complete and the provider URL + reference-only +- **AND** the retained query performs no provider download + +#### Scenario: Thread timestamp is atomic + +- **GIVEN** an accepted outbound message has timestamp `T` +- **WHEN** a writer commits the message and thread update +- **THEN** a reader sees either the complete prior generation or the complete + generation containing both the message and the updated thread timestamp +- **AND** it never sees a timestamp advance without the message + +### Requirement: All generated send surfaces share one outcome contract + +The send producer SHALL be transport-neutral. HTTP, compiled CLI, and MCP +SHALL call the same capture helper and SHALL expose the same structured outcome +codes, accepted-prefix identities, ambiguity semantics, and automatic-retry +policy. Any added error/result schemas SHALL be declared once in +`api/operations.yaml` and projected through Hydra. A provider name SHALL NOT +appear in a generated operation name, route, or core capture type. + +The existing provider-only v1 list operations SHALL not silently change as a +side effect of this capture contract. COD-492/COD-544 own the versioned +retained reader and its compatibility path; retained queries SHALL answer from +the durable backend without invoking acquisition or hidden provider ownership +lookups. + +#### Scenario: Surface parity + +- **GIVEN** the same synthetic accepted-prefix and capture-failure fixtures +- **WHEN** the operation is invoked through generated HTTP, compiled CLI, and + MCP +- **THEN** each surface reports the same outcome code and accepted identities +- **AND** no surface performs a provider call while reading the retained result +- **AND** no surface adds a provider-specific route or retry behavior + +### Requirement: Ambiguous results and reconciliation are explicit + +When an external provider cannot establish whether a request was accepted, the +producer SHALL return `outbound_ambiguous` and preserve the source-supported +reconciliation key, if any. It SHALL not fabricate a successful retained record, +turn ambiguity into rejection, or automatically retry. A later source-authority +reconciliation may use the same configured-instance-qualified identity and +idempotent capture rules; such reconciliation is a separate producer action and +not a retained-query side effect. + +#### Scenario: Ambiguous single send + +- **GIVEN** the provider connection fails after dispatch with no authoritative + acceptance response +- **WHEN** the producer reports the send +- **THEN** it returns `outbound_ambiguous` +- **AND** it does not claim durable success or durable rejection +- **AND** it does not automatically resend +- **AND** any later source-authoritative echo can reconcile once without + creating a duplicate diff --git a/openspec/changes/add-outbound-history/tasks.md b/openspec/changes/add-outbound-history/tasks.md new file mode 100644 index 0000000..3581726 --- /dev/null +++ b/openspec/changes/add-outbound-history/tasks.md @@ -0,0 +1,101 @@ +# Tasks: Outbound History Capture + +This file is the living execution plan for the COD-475 producer contract. +Every task is unchecked because this PR is specification-only. The listed +owners and predecessors are planning boundaries; they do not authorize runtime, +migration, credential, release, deployment, or live-provider work from this +specification PR. + +## Contract gate + +- [ ] T1 — **Owner: Iris PM / COD-475; predecessor: Shiv's storage-first + ruling.** Review and approve `proposal.md`, `design.md`, and the outbound + history spec. Confirm the producer/reader boundary, identity inputs, + accepted-prefix behavior, post-acceptance failure matrix, attachment + coverage, and generated error requirements. Keep all frozen sibling artifacts + byte-unchanged. + +- [ ] T2 — **Owner: COD-475 implementation lead; predecessor: T1 and reviewed + COD-544 retained-record seam.** Convert the approved contract into one + dependency-ordered implementation issue/PR plan. Name the exact generated + error/result schemas, provider trait changes, migration decision, and + compatibility strategy before coding. Do not begin if the retained writer + cannot atomically commit the producer record. + +## Core and durable producer foundation + +- [ ] T3 — **Owner: COD-475 core slice; predecessor: T2 and COD-544's typed + retained-record contract.** In `crates/iris-core/src/`, add zero-I/O typed + capture values for an accepted normalized result, source/instance identity, + replay/reconciliation material, accepted prefixes, attachment coverage, and + structured post-acceptance outcomes. Preserve existing `MessageProvider` + semantics until the implementation contract is explicitly versioned; do not + infer direction or identity from body text or caller role. + +- [ ] T4 — **Owner: COD-475 storage slice; predecessor: T3 and COD-492's + retained writer seam.** Extend the durable storage boundary so a captured + outbound record, required thread/contact updates, replay identity, audit + result, and retained visibility generation commit atomically. Matching + captures are idempotent; immutable conflicts write nothing. If state schema + evolution is required, create a separate reviewed migration gate and prove + old-state recovery before changing the snapshot format. + +## Provider and shared-surface integration + +- [ ] T5 — **Owner: COD-475 provider-adapter slice; predecessor: T3/T4.** Make + each supported send provider expose enough source-neutral information to + distinguish accepted results, rejected results, accepted prefixes, and + ambiguous outcomes. Preserve provider-specific normalization inside + `iris-providers`; do not add provider-named public operations. Cover text, + attachment, source-echo, and configured-instance collision fixtures. + +- [ ] T6 — **Owner: COD-475 shared producer slice; predecessor: T4/T5.** Add one + transport-neutral capture helper used by server, compiled CLI, and MCP. It + must commit accepted records after external acceptance, surface capture/audit + failures without automatic resend, retain accepted prefixes, and preserve + the existing successful message value unless an approved generated contract + changes it. + +- [ ] T7 — **Owner: COD-475 generated-boundary slice; predecessor: T6 and + approved error/result schemas.** Declare any new structured send errors once + in `api/operations.yaml`, regenerate committed HTTP/CLI/MCP projections, and + verify that all three surfaces report identical outcome codes, accepted + identities, ambiguity, and retry policy. No bespoke transport adapter or + source-specific route is allowed. + +## Reader handoff and acceptance + +- [ ] T8 — **Owner: COD-492 reader slice; predecessor: T4 and the reviewed + COD-544 contract.** Read captured outbound records through the retained + snapshot/page seam with zero provider calls. Verify exact body, direction, + timestamp, configured instance, attachments, thread timestamp, coverage, and + cross-process/restart behavior. Do not move reader ownership into COD-475. + +- [ ] T9 — **Owner: COD-475 acceptance slice; predecessor: T6/T7/T8.** Exercise + the real generated HTTP, compiled CLI, and MCP send→retained-read path with + deterministic synthetic providers. Cover accepted text, accepted prefix, + source echo, identity conflict, persistence failure, audit failure, + ambiguity, attachment coverage, same-type instance collision, and mutation + during paging. Confirm no automatic resend and zero upstream calls during the + retained query. + +- [ ] T10 — **Owner: COD-475 documentation slice; predecessor: T7/T9.** Update + only the current send/history limitations and recovery guidance that the + implemented behavior proves. Do not claim complete source history, + encryption, publication, deployment, release, or live-provider validation. + +## Verification and release boundary + +- [ ] T11 — **Owner: each implementation PR; predecessor: its own code slice.** + Run `cargo build --all-targets`, `cargo test --all-targets`, + `cargo clippy --all-targets -- -D warnings`, `cargo fmt --all -- --check`, + `cargo run --locked -p iris-codegen --bin iris-codegen -- check`, and + `git diff --check`. Exercise the nearest generated public boundary and record + exact outputs; private storage/provider tests alone are insufficient. + +- [ ] T12 — **Owner: COD-475 reviewer; predecessor: all four artifacts.** Run + `openspec validate add-outbound-history --strict` if available. If the CLI is + unavailable, structurally verify the four required files, every requirement + and scenario, the ownership/dependency matrix, and `git diff --check`. No + runtime, migration, deployment, release, encryption, or live-provider claim + is implied by this docs-only gate.