diff --git a/.decapod/config.toml b/.decapod/config.toml index c9e1bd2..d7b1fe8 100644 --- a/.decapod/config.toml +++ b/.decapod/config.toml @@ -15,20 +15,15 @@ entrypoints = [ product_name = "amnion" product_summary = "UI/UX layer. The soft place where intent becomes visible. The terminal cockpit, workspace, conversation surface, intent browser, status view, and human interaction layer." architecture_direction = "cli" -product_type = "service_or_library" +product_type = "cli" done_criteria = "Done means Amnion provides a soft, terminal-native Rust TUI where a human can move between governed intents, see each intent’s calm custody state at a glance, and drill into details only when needed: active agent/session, claimed todos, current worktree, touched files, Decapod validation state, approvals, blockers, proof artifacts, handoff summary, and recent meaningful activity. Amnion must not run the agent loop itself; it renders and controls Pincher-managed execution while treating Decapod as the source of governance truth. The default experience should be quiet and confidence-oriented, with minimal log noise, explicit human-attention states, and adjustable verbosity for deeper event streams, raw logs, validation failures, and proof inspection." base_branch = "main" primary_languages = ["Rust"] detected_surfaces = [ "cargo", - "cli", "rust", ] external_tracker = false container_workspaces = true mode = "local" -declared_capabilities = [ - "event-driven", - "persistent-state", - "public-api", -] +declared_capabilities = ["event-driven"] diff --git a/.decapod/generated/Dockerfile b/.decapod/generated/Dockerfile index 64f3703..2967bde 100644 --- a/.decapod/generated/Dockerfile +++ b/.decapod/generated/Dockerfile @@ -2,9 +2,9 @@ # Path: .decapod/generated/Dockerfile # Managed seed: Decapod maintains the image/version header; agents may # mutate project-specific packages and commands in workspace branches. -ARG DECAPOD_IMAGE=ghcr.io/decapodlabs/decapod:v0.72.16 +ARG DECAPOD_IMAGE=ghcr.io/decapodlabs/decapod:v0.72.17 FROM $DECAPOD_IMAGE -ARG DECAPOD_VERSION=0.72.16 +ARG DECAPOD_VERSION=0.72.17 ARG DECAPOD_WORKSPACE_PATH=unknown ARG DECAPOD_USE_LOCAL_BINARY=0 LABEL org.opencontainers.image.base.name="$DECAPOD_IMAGE" diff --git a/.decapod/generated/specs/.manifest.json b/.decapod/generated/specs/.manifest.json index f1d2e40..089cbbb 100644 --- a/.decapod/generated/specs/.manifest.json +++ b/.decapod/generated/specs/.manifest.json @@ -1,90 +1,88 @@ { "schema_version": "1.1.0", "template_version": "scaffold-v3", - "generated_at": "1784668909Z", - "repo_signal_fingerprint": "cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2", + "generated_at": "1784674423Z", + "repo_signal_fingerprint": "e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92", "declared_capabilities": [ - "event-driven", - "persistent-state", - "public-api" + "event-driven" ], "capability_definition_version": "1.0.0", - "config_input_hash": "96631f9f3b3bac9cea573b0c754a44582fffe17492c380e24d041c9f03940868", - "spec_input_hash": "215a9e49cbd8e968c79caddc9f6f92d4e4c0998414f693e25ee6ed9c9269b71f", - "decapod_release": "0.72.16", + "config_input_hash": "540e611f5558bf73e223ce338df0912a664d2112e6c98b51415fa2c03b546851", + "spec_input_hash": "d159fc9d3634d6e94128fa8c581f9c694f53bca2600af4cd1d4647469c22c048", + "decapod_release": "0.72.17", "entrypoints": [ { "path": "AGENTS.md", - "template_hash": "48fbaa679b3ef6ca712491441e2165ac7a06c279ffd0442ba82bfe8e24c20806", - "content_hash": "48fbaa679b3ef6ca712491441e2165ac7a06c279ffd0442ba82bfe8e24c20806", - "fingerprint": "5cda5f37f8f0861d4da34116f0977e2568c52a09adeb649e80511408f24f8827" + "template_hash": "01ce5667894ff4eb0d880485a53e652659c7b4303f3efe4d5f1de3e582518e30", + "content_hash": "01ce5667894ff4eb0d880485a53e652659c7b4303f3efe4d5f1de3e582518e30", + "fingerprint": "2593eca977e8714aea6c25d26324eedb8d14f6701e8cb01ea6f823724c15a36b" }, { "path": "CLAUDE.md", - "template_hash": "1a6e8834b3d1fa75f62cba14f7702db9c716faee63e590833116b96ecb1b0fc2", - "content_hash": "1a6e8834b3d1fa75f62cba14f7702db9c716faee63e590833116b96ecb1b0fc2", - "fingerprint": "1f983c24666a1f41d84a5e5ea3f8b5941981752411ec8fd22dddafbf5b95394e" + "template_hash": "71ac1dccfaa35f63a1b20120d575b3e6e56db9d2c5f418954e17c38034901d39", + "content_hash": "71ac1dccfaa35f63a1b20120d575b3e6e56db9d2c5f418954e17c38034901d39", + "fingerprint": "9aac982616bc33f7c72c96b9178dca3f8657e65ace5269492dab38d767a98254" }, { "path": "GEMINI.md", - "template_hash": "6a2dde574607f3a59e92f8f8ba2cda53340ed9c0a69cf41d63646a3bf440b72f", - "content_hash": "6a2dde574607f3a59e92f8f8ba2cda53340ed9c0a69cf41d63646a3bf440b72f", - "fingerprint": "a952a85e11180bbdd9192521932ebf41289187f7a69b17ba5cdb0d33819f3606" + "template_hash": "c808430d60e19e5abe133bb8bf897da86c763279027322ee1caf9a34fa483169", + "content_hash": "c808430d60e19e5abe133bb8bf897da86c763279027322ee1caf9a34fa483169", + "fingerprint": "9692d0076622101af553f75e85fb46a4b501e0beca3e6ff92e5ee2f99517bc3e" }, { "path": "CODEX.md", - "template_hash": "d133490547ab2fa67e300fdb75dd34b3637505815032e1f9345d703280e581e8", - "content_hash": "d133490547ab2fa67e300fdb75dd34b3637505815032e1f9345d703280e581e8", - "fingerprint": "4b73d322dddc92be7fd6035430abc84924081bf0de751ecbe76bc3bc5b73b26c" + "template_hash": "2443c82255925ac6ada6589ebbcbf451c075397ae2695dbaef25c1f18c006c16", + "content_hash": "2443c82255925ac6ada6589ebbcbf451c075397ae2695dbaef25c1f18c006c16", + "fingerprint": "c86ab9acf92578772e910a94c1404884515971093b45333d9fee82915afb1690" } ], "files": [ { "path": ".decapod/generated/specs/README.md", "template_hash": "7a85d851e4e267dd9eec6ae0dee0417fd6ff3dbca435bc92c6e9aa7c26487813", - "content_hash": "db516bf478b294651a96f71a5a0908b851801b4f3d6098a95196575660cd991a", + "content_hash": "3296ae7337aa0325c200a9768bbd2dedc79373596e74c1e71d5b9cca36fed548", "fingerprint": "" }, { "path": ".decapod/generated/specs/INTENT.md", "template_hash": "e6c06430e23ca87ae33169a6b1f9c70a3fd283655f5b5b29755fb83181f989e9", - "content_hash": "6597a944c6d55db413a9148443548d0c8221cd3fa035780d8a6c19161af2844a", + "content_hash": "ae08041fb8956b73503b62223e3f7d06339682e52e50742c61cdca98bfa95cbf", "fingerprint": "" }, { "path": ".decapod/generated/specs/ARCHITECTURE.md", "template_hash": "f81224214db72e367670029174b0b69cf21ea2240d14ba936257324f2a9449d7", - "content_hash": "2de2ce8770b2a60c38c2657a0118d117c5027ff07ccd04c054bbe2e7878bab2c", + "content_hash": "5289f8e3de245509a282788314b53ff072b77493fb79db08fb696c171d80dad7", "fingerprint": "" }, { "path": ".decapod/generated/specs/INTERFACES.md", "template_hash": "15cc8c3bbfe951da695384106eb78a44c7af28ddc002bcc83c8a7770229fe999", - "content_hash": "676247f5265f81536cffefa1566b36e9501a7aee93eaa8920cb61716f4eb81e3", + "content_hash": "1f7855d1a14fd878c8ac606fb87a81dee52dd10a6f48de7eea84e2b2995788a8", "fingerprint": "" }, { "path": ".decapod/generated/specs/VALIDATION.md", "template_hash": "66c5f8821df14298ff57951351980899465cce41a3e4de98c3893c00116e51fb", - "content_hash": "e664397b1ae949a03921e9630d37ff539d2066ff3e5795c5a322849c8504de04", + "content_hash": "5412cc3431ecb4308c1c72b7bd5364683c113339378a6a12bc8b7e634b7be25f", "fingerprint": "" }, { "path": ".decapod/generated/specs/SEMANTICS.md", "template_hash": "3a79850c94b81e29c6462a7ea70d45198252e3eeba2132c6e9206f7b4e4a5829", - "content_hash": "8088d77cf28f1c96bcbc62b393e0266972dcac3bc211bd3bd972b4deca62c958", + "content_hash": "f1eaaf140b73f9915a742db56d40471d8ac8109f6af99198afda8578c7d626ed", "fingerprint": "" }, { "path": ".decapod/generated/specs/OPERATIONS.md", "template_hash": "54ab819ae22bea9f786793a37196749cbacd72c60f58108898e9db044c534acf", - "content_hash": "8d016e46667a577d2c7a290c26882c39acbae0bcdd3926ce905241c56506afe1", + "content_hash": "0ac820470ef88de3a9e3c2172e7f02962ee5c5783be7f3181cca481109022a3a", "fingerprint": "" }, { "path": ".decapod/generated/specs/SECURITY.md", "template_hash": "b283bdc62b7c2fce411becf6b31928d1b3502da28be7ad618531e3e1820fc147", - "content_hash": "870536db4bb02c598be95693bc08681b7bbb4b0b7f6ba8490e57173167d8055d", + "content_hash": "caec1de8c196be846ad2cae949c4549e3db0e10c6f1ef4e167bdec731dfe2d5e", "fingerprint": "" } ] diff --git a/.decapod/generated/specs/ARCHITECTURE.md b/.decapod/generated/specs/ARCHITECTURE.md index 331d028..c3593f9 100644 --- a/.decapod/generated/specs/ARCHITECTURE.md +++ b/.decapod/generated/specs/ARCHITECTURE.md @@ -1,198 +1,124 @@ # Architecture - - -## Persistent State Architecture Overlay - -### State Ownership -- Each entity type MUST have a designated state owner -- State ownership boundaries MUST be explicitly documented -- Cross-boundary state access MUST go through defined interfaces - -### Transaction Boundaries -- All multi-entity mutations MUST occur within explicit transactions -- Transaction boundaries MUST be documented in ARCHITECTURE.md -- Compensating transactions for distributed operations - -### Storage Abstraction -- Storage ownership, consistency behavior, and access boundaries MUST be explicit -- Portability or swappable implementations are project decisions, not universal requirements -- Migration and rollback treatment MUST match the selected storage technology - - ## Direction +cli -Rust terminal application. Amnion is a host/projection layer, not an agent -runtime. - -## Executive Summary - -Amnion is a foreground terminal host that projects Pincher execution and -Decapod authority into a calm human-facing workflow. - -## Runtime and Deployment Matrix +## What This Project Is +amnion is a cli project built using Rust. +cli -- Runtime: foreground Rust terminal process. -- Environment: local Decapod-managed repository/workspace. -- Deployment: local host; no service deployment owned by Amnion. - -## Implementation Strategy - -Build the smallest Pincher event/state projection first, then add detail views -and human controls only with authoritative result proof. +Architectural principles: +- **Simplicity**: Keep components focused and reusable. +- **Modularity**: Clearly defined interface boundaries and dependency separation. +- **Reliability**: Graceful failure handling and thorough verification. ## Current Facts - -- Runtime/language: Rust. -- Surfaces: Cargo and terminal UI. -- Product type: CLI host/projection. +- Runtime/languages: Rust +- Detected surfaces/framework hints: cargo, rust +- Product type: cli ## Architecture Map - -- View model and TUI presentation. -- Pincher integration adapter. -- Decapod authority/query boundary. +This project's architecture consists of the following key layers/directories: +- `src/`: Main source directory containing primary logic. +- `tests/`: Integration and unit test suite. ## Data Flows +- Inbound request/command parses and validates at the entrypoint. +- Core runtime handles business logic and initiates queries or state changes. +- Storage adapter reads or writes data to the underlying persistence layers. -Pincher events and Decapod results enter the adapter, become a view projection, -and explicit human controls return through the governed owner. +## Strongest Existing Primitives +- Define the strongest existing primitives in the codebase (e.g., helper utilities, base controllers, data access layers). ## Topology - ```mermaid flowchart LR - HUMAN[Human] --> TUI[Amnion TUI] - TUI --> CONTROL[Host controls] - CONTROL --> PINCHER[Pincher loop engine] - PINCHER --> DECAPOD[Decapod control plane] - PINCHER --> EVENTS[Typed state/events] - EVENTS --> TUI - DECAPOD --> AUTH[Authoritative approvals, validation, proof] - AUTH --> TUI + U[User] --> C[CLI Entrypoint] + C --> R[Command Router] + R --> E[Core Engine] + E --> S[(Local Store)] + E --> X[External APIs / Filesystem] ``` -## System Topology - -Human -> Amnion TUI -> Pincher -> Decapod; Pincher events and Decapod evidence -return to Amnion for projection. - ## Store Boundaries - -Amnion owns only ephemeral selection/filter/verbosity state. Decapod owns -durable governance records. - -## Layer boundaries - -- View model: calm projections of Pincher and Decapod state. -- Interaction layer: explicit user actions routed to Pincher/Decapod and - confirmed by returned evidence. -- Integration adapter: consumes Pincher's typed state/events and reads the - Decapod references needed for detail views. -- No provider, tool, retry, patch, or loop implementation belongs in Amnion. - -## Strongest Existing Primitives - -The current strongest primitives are the project context/specs and the planned -Pincher event/state consumer boundary. - -## State ownership - -| State | Owner | Amnion behavior | -| --- | --- | --- | -| Loop execution and event identity | Pincher | Render and retain references | -| Session, todo, workspace, approval, validation, proof | Decapod | Query/project; never replace | -| Selection, filters, verbosity, panel layout | Amnion | Local ephemeral view state | - -## Runtime model - -Amnion starts as a foreground local TUI. It refreshes from Pincher events and -Decapod authority, renders a quiet summary by default, and expands into detail -on demand. It must remain responsive while a loop runs, but background work is -owned by Pincher; Amnion only manages its view/update lifecycle. - -## Execution Path - -Load authority, render projection, route explicit control, await authoritative -result, and refresh. +```mermaid +flowchart LR + I[Inbound Requests] --> C[Core Logic] + C --> W[(Write Store)] + C --> R[(Read Store)] +``` ## Happy Path Sequence - -Read authority -> render state -> human opens detail -> route control -> show -authoritative result. +```mermaid +sequenceDiagram + participant U as User + participant C as CLI + participant E as Core Engine + participant S as Store + U->>C: Run command + C->>E: Parse + validate + E->>S: Persist mutation + S-->>E: Ack + E-->>C: Result + C-->>U: Structured output +``` ## Error Path +```mermaid +sequenceDiagram + participant Client + participant Service + participant Store + Client->>Service: Request + Service->>Store: Database Query + Store--xService: Error/Timeout + Service-->>Client: Typed Error / Recovery Instructions +``` -Unavailable, stale, contradictory, or blocked source data remains visible as -attention state and never becomes optimistic success. +## Execution Path +- Ingress parse + validation: +- Policy/interlock checks: +- Core execution + persistence: +- Verification and artifact emission: ## Concurrency and Runtime Model - -Amnion remains a foreground view; Pincher owns background execution and event -production. +- Execution model: +- Isolation boundaries: +- Backpressure strategy: +- Shared state synchronization: ## Deployment Topology - -Local terminal process; deployment and service hosting are outside scope. +- Runtime units: +- Region/zone model: +- Rollout strategy (blue/green/canary): +- Rollback trigger and blast-radius scope: ## Data and Contracts - -Pincher typed state/events and Decapod references are the integration contract. - -## Schema and Data Contracts - -Amnion consumes Pincher event/state values and Decapod references; it owns no -durable governance schema. - -## API and ABI Contracts - -The initial host boundary is typed Rust/local serialized data. A transported -contract requires explicit versioning and consumer proof. - -## Validation Gates - -Projection tests, contract fixtures, formatting/lints, and `decapod validate` -block promotion. - -## Operational Planes - -Amnion owns view responsiveness and readable attention states; Pincher owns -execution; Decapod owns custody and proof. - -## Failure Topology and Recovery - -Unavailable or stale sources remain visible, unsafe controls stop, and the view -refreshes from authoritative custody before resuming. - -## Delivery Plan - -1. Build the smallest event/state projection. -2. Add detail and attention views. -3. Add explicit controls and transport only with contract proof. - -## Risks and Mitigations - -| Risk | Mitigation | -| --- | --- | -| UI infers authority | retain source ids and wait for authoritative results | -| Runtime/UI contract drift | maintain paired Pincher and Amnion living specs | +- Inbound contracts (CLI/API/events): +- Outbound dependencies (datastores/queues/external APIs): +- Data ownership boundaries: +- Schema evolution + migration policy: ## ADR Register +| ADR | Title | Status | Rationale | Date | +|---|---|---|---|---| +| ADR-001 | Initial topology choice | Proposed | Define first stable architecture | YYYY-MM-DD | -| ADR | Title | Status | -| --- | --- | --- | -| ADR-001 | Split UI host from loop engine | Accepted | - -## Boundary decision +## Delivery Plan (first 3 slices) +- Slice 1 (ship first): +- Slice 2: +- Slice 3: -The Pincher split is intentional: Pincher provides execution semantics and -Amnion provides human experience. Any cross-repository change must update both -living interface specs and include a consumer proof. +## Risks and Mitigations +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Contract drift across components | Medium | High | Spec + schema checks in CI | +| Runtime saturation under peak load | Medium | High | Capacity model + load tests | ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/generated/specs/INTENT.md b/.decapod/generated/specs/INTENT.md index c4a641a..a1c3218 100644 --- a/.decapod/generated/specs/INTENT.md +++ b/.decapod/generated/specs/INTENT.md @@ -5,65 +5,109 @@ ## Declared Capability Surfaces - `event-driven` -- `persistent-state` -- `public-api` ## Product Outcome - -Amnion is the human-facing Rust terminal UI/UX for Pincher-managed governed -execution. It turns typed runtime state and events into a calm cockpit where a -human can understand custody, attention, progress, blockers, approvals, proof, -and handoff without reading a noisy raw log stream. +- UI/UX layer. The soft place where intent becomes visible. The terminal cockpit, workspace, conversation surface, intent browser, status view, and human interaction layer. + +## What This Project Is +amnion is a cli project built using Rust. +UI/UX layer. The soft place where intent becomes visible. The terminal cockpit, workspace, conversation surface, intent browser, status view, and human interaction layer. + +Key operating facts: +- **Primary languages**: Rust +- **Detected surfaces**: cargo, rust + +## Product View +```mermaid +flowchart LR + U[Primary User] --> P[amnion] + P --> O[User-visible Outcome] + P --> G[Proof Gates] + G --> E[Evidence Artifacts] +``` + +## Inferred Baseline +- Repository: amnion +- Product type: cli +- Primary languages: Rust +- Detected surfaces: cargo, rust ## Scope - -| Area | Amnion owns | Authority | -| --- | --- | --- | -| Presentation | Intent browser, workspace/conversation views, status projection, and adjustable detail | Amnion | -| Human control | Explicit actions that request or acknowledge a governed transition | Decapod records result | -| Execution | Start/stop/view controls delegated to Pincher | Pincher | -| Governance | Session, task, workspace, approval, validation, proof, and promotion truth | Decapod | - -## Non-goals - -- Do not implement the agent loop or provider/tool orchestration. -- Do not write a parallel approval, todo, worktree, or proof database. -- Do not present a local optimistic action as an approved or promoted result. -- Do not require a specific backend transport until the Pincher host contract is - versioned. +| Area | In Scope | Proof Surface | +|---|---|---| +| Core workflow | Define a concrete user-visible workflow | Acceptance criteria + tests | +| Data contracts | Document canonical inputs/outputs | [INTERFACES.md](./INTERFACES.md) and schema checks | +| Delivery quality | Block promotion on broken proof surfaces | [VALIDATION.md](./VALIDATION.md) blocking gates | + +## Non-Goals (Falsifiable) +| Non-goal | How to falsify | +|---|---| +| Feature creep beyond the primary outcome | Any PR adds capability not tied to outcome criteria | +| Shipping without evidence | Missing validation artifacts for promoted changes | +| Ambiguous ownership boundaries | Missing owner/system-of-record in interfaces | ## Constraints - -- Rust-first, terminal-native local host. -- Pincher owns execution; Decapod owns governance truth. -- Amnion's view state is a projection and cannot grant approval or promotion. - -## Acceptance Criteria - -- [ ] A human can move between governed intents and identify active custody at - a glance. -- [ ] A detail view exposes agent/session, todo, worktree, touched files, - validation, approvals, blockers, proofs, and handoff summary. -- [ ] Quiet mode shows meaningful activity; adjustable verbosity exposes event - detail, raw logs, validation failures, and proof inspection. -- [ ] Every rendered status retains Pincher/Decapod identifiers and source - timestamps needed to trace it to authority. -- [ ] Human actions are explicit, reversible where possible, and followed by a - Decapod result before the projection changes to an authoritative state. -- [ ] The host builds/tests cleanly and `decapod validate` passes. - -## Assumptions - -- Pincher is the first execution producer and Amnion is its first host. -- The initial implementation can consume serialized Rust/event values locally; - a long-lived transport is deferred until a concrete need is proven. -- Amnion is intentionally local-first and terminal-native. +- Technical: runtime, dependency, and topology boundaries are explicit. +- Operational: deployment, rollback, and incident ownership are defined. +- Security/compliance: sensitive data handling and authz are mandatory. + +## Acceptance Criteria (must be objectively testable) +- [ ] Done means Amnion provides a soft, terminal-native Rust TUI where a human can move between governed intents, see each intent’s calm custody state at a glance, and drill into details only when needed: active agent/session, claimed todos, current worktree, touched files, Decapod validation state, approvals, blockers, proof artifacts, handoff summary, and recent meaningful activity. Amnion must not run the agent loop itself; it renders and controls Pincher-managed execution while treating Decapod as the source of governance truth. The default experience should be quiet and confidence-oriented, with minimal log noise, explicit human-attention states, and adjustable verbosity for deeper event streams, raw logs, validation failures, and proof inspection. +- [ ] Non-functional targets are met (latency, reliability, cost, etc.). +- [ ] Validation gates pass and artifacts are attached. +- [ ] `cargo test` passes for unit/integration coverage +- [ ] `cargo clippy -- -D warnings` passes with no denied lints +- [ ] `cargo fmt --check` passes on the repo + +## Epistemic Custody Fields + +### Active Assumptions +- [ ] List any assumptions made to proceed. +- [ ] Flag assumptions that require future verification. + +### Confidence & Risk Level +- **Confidence**: Low/Medium/High (Rationale: ) +- **Risk**: Low/Medium/High (Impact of wrong assumptions: ) + +### Measured vs Inferred Facts +| Fact | Source (Provenance) | Type (Measured/Inferred) | +|---|---|---| +| | | | + +### Unresolved Contradictions +- [ ] List any evidence that conflicts with current assumptions or intent. + +### Deferred Questions +- [ ] Questions to be answered later. + +### Stop Conditions +- [ ] Explicit conditions under which the agent should stop and ask for help. + +### Proof Required Before Completion +- [ ] Specific evidence needed to prove the outcome is met. + +## Tradeoffs Register +| Decision | Benefit | Cost | Review Trigger | +|---|---|---|---| +| Simplicity vs extensibility | Faster iteration | Potential rework | Feature set expands | +| Strict gates vs dev speed | Higher confidence | More upfront discipline | Lead time regressions | + +## First Implementation Slice +- [ ] Define the smallest user-visible workflow to ship first. +- [ ] Define required data/contracts for that workflow. +- [ ] Define what is intentionally postponed until v2. + +## Open Questions (with decision deadlines) +| Question | Owner | Deadline | Decision | +|---|---|---|---| +| Which interfaces are versioned at launch? | TBD | YYYY-MM-DD | | +| Which non-functional target is hardest to hit? | TBD | YYYY-MM-DD | | ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/generated/specs/INTERFACES.md b/.decapod/generated/specs/INTERFACES.md index 231dd71..16b8017 100644 --- a/.decapod/generated/specs/INTERFACES.md +++ b/.decapod/generated/specs/INTERFACES.md @@ -1,85 +1,79 @@ # Interfaces - - -## Public API Capability Overlay - -### API Contract Requirements -- All public endpoints MUST define explicit request/response schemas -- Versioning strategy MUST be documented (URL path or header-based) -- All public endpoints MUST implement idempotency for mutating operations -- Rate limiting and pagination MUST be implemented for list endpoints - -### Compatibility Guarantees -- Backward-compatible changes ONLY within a version -- Breaking changes require new version (v1, v2, etc.) -- Deprecation and removal policy MUST be selected for this project and proven against its consumers - -### Security Requirements -- All public endpoints MUST implement authentication -- Abuse-control enforcement point MUST be a documented project decision -- Input validation MUST reject malformed requests with typed errors - - -## Inbound Contracts - -Pincher provides typed runtime state/events and custody identifiers. Decapod -provides authoritative approval, validation, and proof results. +## Contract Principles +- Prefer explicit schemas over implicit behavior. +- Every mutating interface defines idempotency semantics. +- Every failure path maps to a typed, documented error code. + +## Generated Contract Depth +Generated interface specs should include: +- API/CLI contracts with request/response schemas. +- Read/write ownership for each storage path. +- Idempotency and retry behavior for mutations. +- Typed failure classes and recovery instructions. + +## API / RPC Contracts +| Interface | Method | Request Schema | Response Schema | Errors | Idempotency | +|---|---|---|---|---|---| +| `TODO` | `TODO` | `TODO` | `TODO` | `TODO` | `TODO` | + +## Event Consumers +| Consumer | Event | Ordering Requirement | Retry Policy | DLQ Policy | +|---|---|---|---|---| +| `TODO` | `TODO` | `TODO` | `TODO` | `TODO` | ## Outbound Dependencies +| Dependency | Purpose | SLA | Timeout | Circuit-Breaker | +|---|---|---|---|---| +| `TODO` | `TODO` | `TODO` | `TODO` | `TODO` | -Human controls route to Pincher/Decapod through the governed integration; no -parallel store or provider adapter is owned by Amnion. - -## Pincher Host Contract - -Pincher produces typed runtime state and events. Amnion consumes them as a -projection and preserves the identifiers needed to query authoritative -Decapod state. - -| Contract | Producer | Consumer | Authority | -| --- | --- | --- | --- | -| Agent/run state | Pincher | Amnion view model | Pincher for runtime | -| `Event` stream | Pincher | Amnion activity/detail views | Pincher event identity | -| Session/task/workspace/work-unit refs | Pincher | Amnion | Decapod records | -| Approval/validation/proof result | Decapod via Pincher | Amnion | Decapod | - -Every event projection retains event id, timestamp, event type, source, and -optional session/task/work-unit ids. Unknown event types remain visible as -safe generic activity rather than being discarded or reinterpreted. +## Inbound Contracts +- API / RPC entrypoints: +- CLI surfaces: +- Event/webhook consumers: +- Repository-detected surfaces: cargo, rust ## Data Ownership - -Amnion owns projections and local view state. Pincher owns runtime state/events; -Decapod owns durable custody and proof. - -## Human actions - -Human actions are commands to inspect, start/stop, request attention, or route -an approval decision through the governed integration. Amnion displays a -pending state until Pincher/Decapod returns a typed result. UI state alone can -never grant approval, mark proof complete, or promote a run. +- Source-of-truth tables/collections: +- Cross-boundary read models: +- Consistency expectations: + +## Error Taxonomy Example (cli) +```rust +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("validation failed: {0}")] + Validation(String), + #[error("upstream timeout")] + UpstreamTimeout, + #[error("conflict: {0}")] + Conflict(String), +} +``` ## Failure Semantics - -| Failure | Projection | Action | -| --- | --- | --- | -| Event/provider delay | stale/pending indicator with last update | wait or inspect | -| Decapod interlock | blocked + required approval reference | route human attention | -| Validation/proof failure | failed-with-cause + evidence link | inspect/handoff | -| Lost/invalid custody reference | unavailable + explicit source error | do not infer state | - -## Compatibility - -The initial consumer can use local typed Rust values or serialized events. A -transport contract is deferred; when introduced it must be versioned and name -producer, consumer, lifecycle, correlation/idempotency fields, error mapping, -and migration evidence. +| Failure Class | Retry/Backoff | Client Contract | Observability | +|---|---|---|---| +| Validation | No retry | 4xx typed error | warn log + metric | +| Dependency timeout | Exponential backoff | 503 with retryable code | error log + alert | +| Conflict | Conditional retry | 409 with conflict detail | info log + metric | + +## Timeout Budget +| Hop | Budget (ms) | Notes | +|---|---|---| +| Client -> Edge/API | 500 | Includes auth + routing | +| API -> Domain | 300 | Includes validation | +| Domain -> Store/Dependency | 200 | Includes retry overhead | + +## Interface Versioning +- Version strategy (`v1`, date-based, semver): +- Backward-compatibility guarantees: +- Deprecation window and removal policy: ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/generated/specs/OPERATIONS.md b/.decapod/generated/specs/OPERATIONS.md index 97ac972..4a1ad5b 100644 --- a/.decapod/generated/specs/OPERATIONS.md +++ b/.decapod/generated/specs/OPERATIONS.md @@ -1,69 +1,86 @@ # Operations - +## Operational Readiness Checklist +- [ ] On-call ownership defined. +- [ ] SLOs and alert thresholds defined. +- [ ] Dashboards for latency/errors/throughput are live. +- [ ] Runbooks linked for all Sev1/Sev2 alerts. +- [ ] Rollback plan validated. +- [ ] Capacity guardrails documented. -## Persistent State Operations Overlay - -### Backup & Recovery -- Backup scope, schedule, retention, and restore evidence MUST be selected for the project -- Recovery point objectives MUST be explicit project decisions, not assumed values -- Recovery time objectives MUST be explicit project decisions, not assumed values -- Restore verification cadence MUST be recorded with the operational proof plan - -### Migration Operations -- All schema changes via migration files -- Migration rollback procedures documented -- Zero-downtime migration strategy for production -- Migration health checks and rollback triggers - - -## Host lifecycle - -1. Start in a local foreground session. -2. Discover the Pincher/Decapod run list and establish a read projection. -3. Subscribe or poll for typed state/events with bounded refresh work. -4. Render quiet meaningful activity by default. -5. Route explicit controls to Pincher and wait for authoritative results. -6. Surface approvals, blockers, validation, proofs, and handoff evidence. +## Deployment Model +Describe the operational runtime model, scheduling, and system deployment architecture. ## Service Level Objectives - -The TUI should remain responsive while Pincher runs; exact latency targets are -deferred until the first real adapter and view workload exist. +| SLI | SLO Target | Measurement Window | Owner | +|---|---|---|---| +| Availability | 99.9% | 30d | TBD | +| P95 latency | TBD | 7d | TBD | +| Error rate | < 1% | 7d | TBD | ## Monitoring - -Monitor source freshness, event lag, authority/query errors, refresh failures, -and unresolved attention states. +| Signal | Metric | Threshold | Alert | +|---|---|---|---| +| Traffic | requests/sec | baseline drift | warn | +| Latency | p95/p99 | threshold breach | page | +| Reliability | error ratio | threshold breach | page | +| Saturation | cpu/memory/queue depth | sustained high | page | + +## Health Checks +- Liveness: +- Readiness: +- Dependency health: +- Synthetic transaction: ## Incident Response - -Show unavailable/stale state, preserve source identifiers, stop unsafe controls, -and route the issue to Pincher/Decapod ownership. - -## Recovery - -If Pincher or Decapod is unavailable, retain the last source timestamp and show -an unavailable/stale state. Do not fabricate progress or approval. Reconnect -using custody identifiers and refresh from authority before resuming controls. - -## Observability - -The default view is concise. Detail mode may show event ids, source, timestamps, -validation errors, proof references, and raw logs subject to redaction. Amnion -does not own Pincher's execution logs or Decapod's audit records. - -## Operational ownership - -- Amnion: TUI responsiveness, refresh lifecycle, rendering failures, and - readable human attention states. -- Pincher: loop execution, retries, cancellation, and event production. -- Decapod: session custody, approvals, validation, proofs, and promotion. +- Detection: +- Triage: +- Mitigation: +- Communication: +- Post-mortem: + +## Rollout Strategy +- Blue/green deployment: +- Canary release: +- Rolling update: +- Feature flags: + +## Capacity Planning +- Traffic patterns: +- Resource utilization: +- Scaling triggers: + +## Logging +Use `tracing` + `tracing-subscriber` with structured JSON output and request correlation ids. + +## Secrets Management +| Secret | Source | Rotation | Consumer | +|---|---|---|---| +| External service auth material | managed runtime configuration | periodic | runtime services | +| Artifact signing material | managed signing service/local secure store | periodic | release pipeline | + +## Security Testing +| Test Type | Cadence | Tooling | +|---|---|---| +| SAST | each PR | language linters/scanners | +| Dependency scan | each PR + weekly | supply-chain tools | +| DAST/pentest | scheduled | external/internal | + +## Compliance and Audit +- Regulatory scope: +- Audit evidence location: +- Exception process: + +## Pre-Promotion Security Checklist +- [ ] Threat model updated for changed surfaces. +- [ ] Auth/authz tests pass. +- [ ] Dependency vulnerability scan reviewed. +- [ ] No unresolved critical/high security findings. ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/generated/specs/README.md b/.decapod/generated/specs/README.md index 68127df..560104f 100644 --- a/.decapod/generated/specs/README.md +++ b/.decapod/generated/specs/README.md @@ -1,22 +1,50 @@ -# Amnion project specs +# Project Specs -These living specs define Amnion as the human-facing terminal host for the -Pincher loop engine and Decapod governance plane. +Canonical path: `.decapod/generated/specs/`. +These files are the project-local contract for humans and agents. -- `INTENT.md` defines the quiet, confidence-oriented TUI outcome. -- `ARCHITECTURE.md` defines projection, control, and state ownership. -- `INTERFACES.md` defines the Pincher event/state consumer boundary. -- `SEMANTICS.md` defines display and human-action semantics. -- `OPERATIONS.md` defines host startup, refresh, and failure handling. -- `SECURITY.md` defines the projection trust boundary. -- `VALIDATION.md` defines host and contract proof surfaces. +## Snapshot +- Project: this repository +- Outcome: Define the intended user-visible outcome. +- Detected languages: not detected yet +- Detected surfaces: not detected yet -Amnion must not run Pincher's loop or duplicate Decapod's authoritative state. +## How to use this folder +- [INTENT.md](./INTENT.md): what success means and what is explicitly out of scope. +- [ARCHITECTURE.md](./ARCHITECTURE.md): topology, runtime model, data boundaries, and ADR trail. +- [INTERFACES.md](./INTERFACES.md): API/CLI/events/storage contracts and failure behavior. +- [VALIDATION.md](./VALIDATION.md): proof commands, quality gates, and evidence artifacts. +- [SEMANTICS.md](./SEMANTICS.md): state machines, invariants, replay rules, and idempotency. +- [OPERATIONS.md](./OPERATIONS.md): SLOs, monitoring, incident response, and rollout strategy. +- [SECURITY.md](./SECURITY.md): threat model, trust boundaries, auth/authz, and supply-chain posture. + +## Canonical `.decapod/` Layout +- `.decapod/data/`: canonical control-plane state (SQLite + ledgers). +- `.decapod/generated/specs/`: **Living project specs** for humans and agents. +- `.decapod/generated/context/`: deterministic context capsules. +- `.decapod/generated/policy/context_capsule_policy.json`: repo-native JIT context policy contract. +- `.decapod/generated/artifacts/provenance/`: promotion manifests and convergence checklist. +- `.decapod/generated/artifacts/custody/`: epistemic custody artifacts (assumptions, contradictions, deferred questions). +- `.decapod/generated/artifacts/inventory/`: deterministic release inventory. +- `.decapod/generated/artifacts/diagnostics/`: opt-in diagnostics artifacts. +- `.decapod/workspaces/`: isolated todo-scoped git worktrees. + +## Day-0 Onboarding Checklist +- [ ] Replace all placeholders in all 8 spec files. +- [ ] Confirm primary user outcome and acceptance criteria in [INTENT.md](./INTENT.md). +- [ ] Confirm topology and runtime model in [ARCHITECTURE.md](./ARCHITECTURE.md). +- [ ] Document all inbound/outbound contracts in [INTERFACES.md](./INTERFACES.md). +- [ ] Define validation gates and CI proof surfaces in [VALIDATION.md](./VALIDATION.md). +- [ ] Define state machines and invariants in [SEMANTICS.md](./SEMANTICS.md). +- [ ] Define SLOs, alerting, and incident process in [OPERATIONS.md](./OPERATIONS.md). +- [ ] Define threat model and auth/authz decisions in [SECURITY.md](./SECURITY.md). +- [ ] Ensure architecture diagram, docs, changelog, and tests are mapped to promotion gates. +- [ ] Run all validation/test commands and attach evidence artifacts. ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/generated/specs/SECURITY.md b/.decapod/generated/specs/SECURITY.md index 57431bc..7c7ba2f 100644 --- a/.decapod/generated/specs/SECURITY.md +++ b/.decapod/generated/specs/SECURITY.md @@ -1,78 +1,89 @@ # Security - - -## Public API Security Overlay - -### Authentication Requirements -- All public endpoints MUST validate authentication tokens -- Token validation MUST include expiry, revocation, and scope checks -- Anonymous access MUST be explicitly documented and justified - -### Input Validation -- All request bodies MUST be validated against schemas -- Reject requests with unknown fields (strict schema validation) -- Size limits MUST be enforced on all request bodies - -### Rate Limiting -- Limits and enforcement boundaries MUST be selected for this deployment -- Clustered enforcement behavior MUST be documented when applicable -- Client-visible throttling behavior MUST be part of the contract when applicable - - ## Threat Model - ```mermaid flowchart LR - HUMAN[Human] --> UI[Amnion projection] - UI --> PINCHER[Pincher integration] - PINCHER --> DECAPOD[Decapod authority] - PINCHER --> REPO[Allowed workspace] - DECAPOD --> AUDIT[Custody, approval, proof] + U[User/Client] --> A[Application Boundary] + A --> D[(Data Stores)] + A --> X[External Dependencies] + I[Identity Provider] --> A + A --> L[Audit Logs] ``` -Amnion is a presentation client, not a trust root. It must treat Pincher -events and provider output as untrusted input for rendering, preserve -Decapod's approval/validation results, and avoid creating authority from -optimistic UI state. +## STRIDE Table +| Threat | Surface | Mitigation | Verification | +|---|---|---|---| +| Spoofing | Auth boundary | strong auth + token validation | auth tests | +| Tampering | State mutation APIs | integrity checks + RBAC | integration tests | +| Repudiation | Critical actions | immutable audit logs | log review | +| Information disclosure | Data at rest/in transit | encryption + classification | security scans | +| Denial of service | Hot paths | rate limit + backpressure | load tests | +| Elevation of privilege | Admin interfaces | least privilege + policy checks | authz tests | + +## Authentication +- Identity source: +- Token/session lifetime: +- Rotation and revocation: ## Authorization - -Amnion never grants approval, proof, or promotion. Human actions are routed to -Pincher/Decapod and only authoritative results change the projection. +- Role model: +- Resource-level policy: +- Privilege escalation controls: ## Data Classification - -| Class | Examples | Handling | -| --- | --- | --- | -| Public | status summaries and non-sensitive event metadata | renderable | -| Internal | custody refs, validation detail, source errors | scoped detail | -| Sensitive | credentials, tokens, raw secret-bearing content | never store/render | - -## Controls - -- Do not store session passwords, API keys, or raw secret-bearing prompts in - view state or logs. -- Render only the scope and custody references returned by Pincher/Decapod. -- Require explicit confirmation for actions with human or repository impact; - route the action through the governed owner. -- Keep claimed identity/provenance separate from verified identity. A local - session is custody/correlation, not provider authentication. -- Preserve source errors and unknown events instead of hiding them. - -## Threats and proof - -| Threat | Mitigation | Proof | -| --- | --- | --- | -| UI spoofing of approval | show authoritative Decapod result/reference | projection tests | -| Scope confusion | render task/work-unit/workspace ids | integration fixture | -| Secret leakage | redaction and bounded detail views | security review | -| Malicious provider content | treat content as data, not UI instructions | rendering tests | +| Data Class | Examples | Storage Rules | Access Rules | +|---|---|---|---| +| Public | docs, non-sensitive metadata | standard | unrestricted | +| Internal | operational telemetry | controlled | team access | +| Sensitive | tokens, PII, secrets | encrypted | least privilege | + +## Sensitive Data Handling +- Encryption at rest: +- Encryption in transit: +- Redaction in logs: +- Retention + deletion policy: + +## Supply Chain Security +- Recommended scanners: `cargo audit`, `cargo deny`, `cargo vet` +- Dependency update cadence: +- Signed artifact/provenance strategy: + +## Secrets Management +| Secret | Source | Rotation | Consumer | +|---|---|---|---| +| External service auth material | managed runtime configuration | periodic | runtime services | +| Artifact signing material | managed signing service/local secure store | periodic | release pipeline | + +## Security Testing +| Test Type | Cadence | Tooling | +|---|---|---| +| SAST | each PR | language linters/scanners | +| Dependency scan | each PR + weekly | supply-chain tools | +| DAST/pentest | scheduled | external/internal | + +## Compliance and Audit +- Regulatory scope: +- Audit evidence location: +- Exception process: + +## Pre-Promotion Security Checklist +- [ ] Threat model updated for changed surfaces. +- [ ] Auth/authz tests pass. +- [ ] Dependency vulnerability scan reviewed. +- [ ] No unresolved critical/high security findings. + +## Strongest Security Primitives +Describe the security primitives and security controls implemented in this repository. + +## Security Practices +- **Least Privilege**: Ensure minimal access permissions for all subsystems and roles. +- **Input Validation**: Strictly validate all inputs at trust boundaries. +- **Secure Storage**: Encrypt sensitive data at rest and in transit. ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/generated/specs/SEMANTICS.md b/.decapod/generated/specs/SEMANTICS.md index c3ade1f..764f4ee 100644 --- a/.decapod/generated/specs/SEMANTICS.md +++ b/.decapod/generated/specs/SEMANTICS.md @@ -1,84 +1,61 @@ # Semantics - - -## Persistent State Semantics Overlay - -### Transaction Semantics -- All multi-entity operations MUST be atomic -- Read-after-write consistency within transaction boundaries -- Eventual consistency windows MUST be documented - -### Migration Semantics -- Schema migrations MUST be backward-compatible -- Migration rollback procedures MUST be documented -- Data integrity checks post-migration - -### Recovery Semantics -- Point-in-time recovery capability -- Recovery objectives MUST be selected for the project and recorded as proof obligations -- Recovery test cadence MUST be selected for the project and recorded as a proof obligation - - ## State Machines - -```mermaid -stateDiagram-v2 - [*] --> Loading - Loading --> Ready - Ready --> Attention - Ready --> Stale - Attention --> Ready - Stale --> Ready - Ready --> HandedOff -``` - -## View state - ```mermaid stateDiagram-v2 - [*] --> Loading - Loading --> Ready - Loading --> Unavailable - Ready --> Attention: interlock/blocker - Ready --> Inspecting: human opens detail - Inspecting --> Ready: close detail - Attention --> Ready: authoritative resolution received - Ready --> Stale: producer update delayed - Stale --> Ready: fresh state received - Ready --> HandedOff: terminal proof/handoff visible + [*] --> Draft + Draft --> InProgress + InProgress --> Verified + InProgress --> Blocked + Blocked --> InProgress + Verified --> [*] ``` -## Projection invariants - -- A projection is never more authoritative than its Pincher/Decapod source. -- `ready`, `blocked`, `failed`, and `handed_off` retain the source identifiers - and evidence references. -- Unknown, delayed, or contradictory source data is shown as attention/stale - rather than silently normalized into success. -- A human action changes view state only after its authoritative result returns. -- Quiet mode may summarize events, but detail mode can recover the full event - identity and source context. - ## Invariants - -| Invariant | Enforcement | -| --- | --- | -| A projection cannot grant authority | only Pincher/Decapod results change authoritative status | -| Source identity is preserved | retain event, run, custody, and proof references | -| Stale or unknown data is visible | render attention/stale rather than optimistic success | -| Human controls are confirmed | wait for authoritative result before updating status | - -## Attention semantics - -Human attention is required for an unresolved Decapod interlock, missing -custody, validation/proof failure, or an explicit handoff decision. Ordinary -provider activity is not an attention state. +| Invariant | Type | Validation | +|---|---|---| +| No promoted change without proof | System | validation gate | +| Canonical source-of-truth per entity | Data | interface/spec review | +| Mutation events are replayable | Data | deterministic replay | + +## Event Sourcing Schema +| Field | Type | Description | +|---|---|---| +| event_id | string | globally unique event id | +| aggregate_id | string | entity/workflow id | +| event_type | string | semantic transition | +| payload | object | transition data | +| recorded_at | timestamp | append time | + +## Replay Semantics +- Replay order: +- Conflict resolution: +- Snapshot cadence: +- Determinism proof strategy: + +## Error Code Semantics +- Namespace: +- Stable compatibility window: +- Mapping to retry/degrade behavior: + +## Domain Rules +- Business rule 1: +- Business rule 2: +- Business rule 3: + +## Idempotency Contracts +| Operation | Idempotency Key | Duplicate Behavior | +|---|---|---| +| create/update mutation | request_id | return original result | +| async enqueue | event_id | ignore duplicate enqueue | + +## Language Note +- Primary language inferred: Rust ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.decapod/generated/specs/VALIDATION.md b/.decapod/generated/specs/VALIDATION.md index 043a7b0..ddf20a8 100644 --- a/.decapod/generated/specs/VALIDATION.md +++ b/.decapod/generated/specs/VALIDATION.md @@ -1,83 +1,112 @@ -# Validation## Proof Surfaces - +# Validation + +## Validation Philosophy +> Validation is a release gate, not documentation theater. + +## Validation Harness +Define the test and verification harness used by this project. +Key features: +- **Automated Tests**: Unit and integration test suites. +- **Linting & Formatting**: Static analysis tools and checkers. +- **CI/CD Integration**: Automatic execution of validation gates on push. + +## Generated Spec Refresh Gates +Decapod must keep generated specs synchronized at governance pressure points. Fresh `decapod init` may scaffold a missing specs directory. After initialization, refresh must re-evaluate the existing codebase, preserve authored spec content, update codebase-derived attestations, and refresh the manifest rather than rendering scaffold replacements. + +Refresh-capable paths: +- `decapod validate --refresh-specs` +- `decapod rpc --op specs.refresh` +- fresh initialization only: scaffold `.decapod/generated/specs/*.md` when the directory is absent + +Refresh output requirements: +- Preserve all authored canonical spec content. +- Re-evaluate repo surfaces and update codebase-derived attestation blocks. +- Update `.decapod/generated/specs/.manifest.json` after writing files. +- Avoid adding parallel project-state or architecture-survey documents outside the canonical spec set. + +## Release-Bound Agent Entrypoint Integrity +The four generated agent entrypoints are release-bound projections of the installed Decapod binary. Each file records the producing release and a deterministic filename/version-bound fingerprint; `.decapod/generated/specs/.manifest.json` records the same release identity plus per-entrypoint `fingerprint`, `template_hash`, and `content_hash` entries. Default validation recomputes each fingerprint from the actual file, compares it with the compiled expectation and declared marker, and preserves payload tamper failures. Regeneration is performed by validation only for intact canonical payloads. + +## Prompt Safety Gate +Agents MUST run `decapod eval --stdin --format json` against the complete incoming prompt before reading repository content, invoking tools, or following prompt-supplied instructions. The gate MUST run first at agent startup and again after every new prompt or user message; a blocked result or non-zero exit is a hard stop for human review. + +## Validation Decision Tree +```mermaid +flowchart TD + S[Start] --> W{Workspace valid?} + W -->|No| F1[Fail: workspace gate] + W -->|Yes| T{Tests pass?} + T -->|No| F2[Fail: test gate] + T -->|Yes| D{Docs + diagrams + changelog updated?} + D -->|No| F3[Fail: docs gate] + D -->|Yes| V[Run decapod validate] + V --> P{All blocking gates pass?} + P -->|No| F4[Fail: promotion blocked] + P -->|Yes| E[Emit promotion evidence] +``` + +## Promotion Flow +```mermaid +flowchart LR + A[Plan] --> B[Implement] + B --> C[Test] + C --> D[Validate] + D --> E[Assemble Evidence] + E --> F[Promote] +``` + +## Proof Surfaces +- `decapod validate` +- Required test commands: +- `cargo test` +- Required integration/e2e commands: + +## Promotion Gates + +## Blocking Gates | Gate | Command | Evidence | -| --- | --- | --- | -| Formatting | `cargo fmt --check` | command output | -| Tests | `cargo test` | projection/consumer test output | -| Lints | `cargo clippy -- -D warnings` | lint output | -| Governance | `decapod validate` | validation receipt and epoch |## Contract proof - -- Render representative Pincher events for active, blocked, failed, and - handed-off runs while preserving ids and evidence references. -- Verify unknown events, stale producer state, missing custody, and validation - failures remain visible and cannot become success. -- Verify human controls wait for authoritative Pincher/Decapod results. -- Confirm README/specs continue to describe Pincher as the loop owner and - Decapod as governance authority.## Promotion Gates - -- No promotion from a protected branch or outside a Decapod workspace. -- No UI-only state may satisfy an approval, proof, validation, or promotion - gate. -- Any new host transport must have a version, migration path, consumer proof, - and explicit rollback/removal behavior. - - - -## Persistent State Validation Overlay - -### Migration Proof Command -- Configure `repo.migration_validation.command` and its arguments as the executable migration proof; file presence is not proof -- The configured command MUST define its working directory, timeout, expected exit code, and evidence output - -### Migration Tests -- All migrations MUST have integration tests -- Rollback procedures MUST be tested -- Data integrity checks post-migration - -### Persistence Integration Tests -- Repository abstraction tested against real database -- Transaction boundary tests -- Concurrency conflict tests -- Data integrity validation after recovery - - - - -## Public API Validation Overlay - -### Contract Tests -- All public endpoints MUST have contract tests -- Request/response schema validation on every request -- Compatibility regression tests for each version - -### Security Tests -- Authentication bypass tests -- Malformed input handling tests -- Rate limit enforcement tests -- Token expiry/revocation tests - +|---|---|---| +| Architecture + interface drift check | `decapod validate` | Gate output | +| Tests pass | project test command | CI + local logs | +| Docs + changelog current | repo docs checks | PR diff | +| Security critical checks pass | security scanner suite | scanner reports | + +## Warning Gates +| Gate | Trigger | Follow-up SLA | +|---|---|---| +| Coverage regression warning | Coverage drops below target | 48h | +| Non-blocking perf drift | P95 regression below hard threshold | 72h | ## Evidence Artifacts - -Record projection fixtures, source event ids, authority references, and the -Decapod validation epoch. Do not retain secrets. +| Artifact | Path | Required For | +|---|---|---| +| Validation report | `.decapod/generated/artifacts/provenance/*` | Promotion | +| Test logs | CI artifact store | Promotion | +| Architecture diagram snapshot | `ARCHITECTURE.md` | Promotion | +| Changelog entry | `CHANGELOG.md` | Promotion | ## Regression Guardrails - -- Unknown or stale source data cannot render as authoritative success. -- UI-only state cannot satisfy approval, proof, or promotion gates. -- Cross-repository interface changes require Pincher consumer evidence. - -## Current implementation boundary - -The repository is currently documentation and governance scaffolding without a -checked-in TUI or Pincher adapter. The first implementation slice should build -the smallest projection contract before adding presentation breadth. +- Baseline references: +- Statistical thresholds (if non-deterministic): +- Rollback criteria: + +## Bounded Execution +| Operation | Timeout | Failure Mode | +|---|---|---| +| Validation | 30s | timeout or lock | +| Unit test suite | project-defined | non-zero exit | +| Integration suite | project-defined | non-zero exit | + +## Coverage Checklist +- [ ] Unit tests cover critical branches. +- [ ] Integration tests cover key user flows. +- [ ] Failure-path tests cover retries/timeouts. +- [ ] Docs/diagram/changelog updates included. ## Codebase Attestation -- Repository signal fingerprint: `cbb46fea4ed69a2e244419b99c731f321e0d22223264c74afc034828705c58f2` -- Significant implementation surfaces: `.github/` (1 files), `README.md/` (1 files) +- Repository signal fingerprint: `e14883da1b4f85ca5e1f0840e457997727677b8135fe87c8d51b9ea1f0dd0d92` +- Significant implementation surfaces: `.github/` (1 files), `Cargo.lock/` (1 files), `Cargo.toml/` (1 files), `README.md/` (1 files), `src/` (8 files) - Refreshed from the current codebase by `decapod specs.refresh` diff --git a/.github/workflows/decapod-validate.yml b/.github/workflows/decapod-validate.yml index 934c3cb..0a982ae 100644 --- a/.github/workflows/decapod-validate.yml +++ b/.github/workflows/decapod-validate.yml @@ -1,13 +1,29 @@ -name: Decapod Validate +name: Amnion Validation on: push: - branches: [ main, master ] + branches: [main, master] pull_request: - branches: [ main, master ] + branches: [main, master] + workflow_dispatch: jobs: - validate: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust 1.90 + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.90.0 + components: rustfmt, clippy + - name: Rust checks + run: | + cargo fmt --check + cargo test + cargo clippy --all-targets --all-features -- -D warnings + + decapod: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,12 +36,12 @@ jobs: key: ${{ runner.os }}-decapod-${{ hashFiles('Cargo.toml', 'Cargo.lock') }} - name: Install Decapod run: | - if ! command -v decapod &> /dev/null; then + if ! command -v decapod >/dev/null 2>&1; then cargo install decapod fi - - name: Decapod Validate + - name: Decapod validation env: DECAPOD_VALIDATE_SKIP_GIT_GATES: 1 run: | - decapod init --proof --force + decapod init --proof decapod validate diff --git a/.gitignore b/.gitignore index a3b45f5..fc8cb6a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .decapod/data/* .decapod/.stfolder .decapod/workspaces +target/ .decapod/generated/* !.decapod/data/ !.decapod/data/knowledge.promotions.jsonl diff --git a/AGENTS.md b/AGENTS.md index b2668ed..568b903 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ - - + + # AGENTS.md — Universal Agent Contract This is a Decapod-managed repository. **Strict Dependency: You are strictly bound to the Decapod governance kernel.** diff --git a/CLAUDE.md b/CLAUDE.md index 0dadcb1..3b8ef5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ - - + + # CLAUDE.md - Agent Entrypoint You are working in a Decapod-managed repository. diff --git a/CODEX.md b/CODEX.md index ceacd1b..f4e5b5f 100644 --- a/CODEX.md +++ b/CODEX.md @@ -1,5 +1,5 @@ - - + + # CODEX.md - Agent Entrypoint You are working in a Decapod-managed repository. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..84d6198 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,706 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "amnion" +version = "0.1.0" +dependencies = [ + "crossterm", + "pretty_assertions", + "ratatui", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eb16c0f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "amnion" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +description = "A calm terminal projection client for governed agent work" +license = "MIT" + +[dependencies] +crossterm = "0.28" +ratatui = "0.29" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[dev-dependencies] +pretty_assertions = "1" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "deny" diff --git a/GEMINI.md b/GEMINI.md index 271fcfe..ad65ab3 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,5 +1,5 @@ - - + + # GEMINI.md - Agent Entrypoint You are working in a Decapod-managed repository. diff --git a/README.md b/README.md index 2a64ca8..e4c8ec4 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,90 @@ # Amnion -Amnion is the human-facing terminal UI/UX for governed agent work. It is the -soft place where Pincher's execution state becomes visible: the cockpit, -workspace view, conversation surface, intent browser, status view, and human -attention flow. +Amnion is the human-facing terminal UI/UX for governed agent work: the quiet +place where Pincher execution becomes visible and Decapod evidence stays +legible. The primary unit is a governed intent, not a log stream. -Amnion renders and controls Pincher-managed execution. It does not run the -agent loop, become a second governance store, or infer approval from a local -UI action. Decapod remains the source of truth for sessions, todos, workspaces, -approvals, validation, proofs, and promotion. +The boundary is deliberate: -## Boundary with Pincher +```text +Amnion -> projects state, routes attention, exposes read-only controls +Pincher -> runs agent/provider/tool execution and emits runtime events +Decapod -> owns sessions, todos, workspaces, approvals, validation, proofs, + and promotion truth +``` -| Concern | Owner | -| --- | --- | -| Context preparation, provider turns, tool/patch proposals, retries, and loop lifecycle | [Pincher](https://github.com/DecapodLabs/pincher) | -| Durable custody, approvals, validation, proof, and promotion gates | Decapod | -| Intent list, calm status projection, conversation/workspace views, and attention routing | Amnion | - -Amnion consumes Pincher's typed state and event stream, preserving run, -session, task, work-unit, workspace, approval, blocker, and proof references. -The UI is a projection: a displayed `ready` state is not authoritative until -the corresponding Decapod evidence says so. +Amnion does not run an agent loop, call a model provider, execute tools, apply +patches, persist governance state, or locally grant approval/proof. A local +Pincher event such as `ready` remains an observation until both validation and +proof evidence are explicitly present from Decapod. Missing, stale, unavailable, +malformed, unknown, and contradictory source data remain visible as such. ## First experience -The default TUI should let a human: +The current source is a deterministic fixture/replay adapter. It is a deliberate +stand-in while Pincher’s host-facing event contract is still provisional; it is +not a claim that Amnion has production Pincher transport integration. + +This first slice is intentionally a foreground demonstration of the projection +boundary: the fixture source can be replaced by a live adapter without changing +the reducer or terminal presentation. + +```bash +cargo run +``` + +The screen opens with twelve representative intents covering working, approval, +blocked, validation failure, proof pending, authoritative readiness, failed, +handed-off, stale, unavailable, unknown-future-event, and conflicting-evidence +states. The default view is quiet and shows meaningful activity and attention +only. + +Controls: + +- `↑` / `↓` or `k` / `j`: move between intents +- `Enter` or `d`: open or close progressively deeper detail +- `v`: cycle `Quiet` → `Summary` → `Detailed` → `Debug` +- `q` or `Esc`: quit + +Verbosity changes presentation only. It cannot hide blockers, failures, stale +state, source conflicts, or missing authority. There are no mutating controls in +this slice. + +## Projection contract + +The reducer in `src/projection.rs` preserves intent, run, session, agent, todo, +work-unit, workspace, touched-file, approval, blocker, validation, proof, +handoff, source-event, timestamp, source-type, freshness, unknown-event, and +diagnostic data. It sorts replay by source timestamp and event ID, ignores +duplicate event IDs, and retains unsupported events for inspection. + +The projection separates observed runtime activity from projected execution +state, authoritative Decapod evidence, human attention, and source freshness. +The fixture adapter implements the small `EventSourceAdapter` boundary so a +future Pincher adapter can replace it without changing the reducer or UI. + +The current producer shape is informed by Pincher’s public Rust types, but the +event envelope is not declared as Pincher’s final public protocol. Pincher must +publish that contract, compatibility policy, and shared fixtures before a real +transport adapter is added. + +## Development + +```bash +cargo fmt --check +cargo test +cargo clippy --all-targets --all-features -- -D warnings +decapod validate +``` -- move between governed intents and see the current custody state at a glance; -- inspect the active agent/session, claimed todo, worktree, touched files, - approvals, blockers, validation state, proof artifacts, and handoff summary; -- expand from quiet meaningful activity into detailed events, raw logs, - validation failures, and proof inspection only when needed; -- take an explicitly labeled human action when Decapod records an approval - decision or a blocked handoff. +Architecture decisions are recorded in +[`docs/adr/001-projection-first.md`](docs/adr/001-projection-first.md). -## Development boundary +## Current limitations -Amnion owns presentation and interaction policy. Pincher owns execution -semantics. Changes that cross that boundary require a versioned interface, -named producer/consumer ownership, and representative event/projection proof. +- The source is fixture/replay only; there is no Pincher process, socket, or + network integration. +- Decapod enrichment and governed human-action routing are follow-on work. +- Detail views currently show compact evidence references rather than full + transcripts, diffs, proof artifacts, or handoff documents. +- Terminal accessibility and narrow-width behavior need a dedicated contract. diff --git a/docs/adr/001-projection-first.md b/docs/adr/001-projection-first.md new file mode 100644 index 0000000..2a62806 --- /dev/null +++ b/docs/adr/001-projection-first.md @@ -0,0 +1,49 @@ +# ADR-001: Establish Amnion as a projection-first terminal client + +- Status: Accepted for the first vertical slice +- Date: 2026-07-21 + +## Context + +Amnion is the human-facing terminal surface for governed agent work. Pincher +owns agent/provider/tool execution. Decapod owns custody and proof-backed +governance truth. A first implementation must make this boundary executable +without inventing a second authority or pretending that Pincher’s current open +event payloads are a stable host protocol. + +## Decision + +Amnion is a foreground Rust + Ratatui projection client. Its domain reducer +consumes a transport-neutral event envelope, produces read-only intent +projections, and preserves source custody, freshness, unknown input, and +contradictory evidence. The UI reads those projections and owns only ephemeral +selection, detail, and verbosity state. + +Decapod remains authoritative for sessions, todos, workspaces, approvals, +validation, proofs, and promotion. Pincher remains authoritative for execution +and runtime activity. Amnion may display and later route human actions, but it +cannot optimistically change governance state. + +The current adapter is fixture/replay data. It exercises real reducer semantics +before a premature transport is introduced. The adapter is isolated behind +`EventSourceAdapter`, so a future Pincher integration can provide subscription, +polling, or replay without changing the projection or presentation layers. + +## Rationale + +- Rust provides a small, foreground-native binary with explicit types and no + required background service. +- Ratatui and Crossterm support a stable, local terminal surface without + turning Amnion into a web, Electron, or persistent application platform. +- Projection semantics precede presentation breadth because truthful custody, + authority, freshness, and attention are more important than widget count. +- Fixtures make unknown, stale, unavailable, malformed, and conflicting input + testable while keeping the producer contract honestly provisional. + +## Consequences + +The first slice has no live Pincher connection, full conversation view, direct +Decapod enrichment, or mutating human controls. Those are separate issues with +their own contracts and proof expectations. In return, replay is deterministic, +duplicate events are idempotent, local `ready` cannot become authoritative +completion, and quiet mode can be tested without weakening safety signals. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..2aa48b9 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,139 @@ +use crate::projection::{Activity, IntentProjection}; +use crossterm::event::{KeyCode, KeyEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verbosity { + Quiet, + Summary, + Detailed, + Debug, +} + +impl Verbosity { + pub fn label(self) -> &'static str { + match self { + Self::Quiet => "Quiet", + Self::Summary => "Summary", + Self::Detailed => "Detailed", + Self::Debug => "Debug", + } + } + + pub fn next(self) -> Self { + match self { + Self::Quiet => Self::Summary, + Self::Summary => Self::Detailed, + Self::Detailed => Self::Debug, + Self::Debug => Self::Quiet, + } + } +} + +#[derive(Debug)] +pub struct App { + projections: Vec, + selected: usize, + pub detail_open: bool, + pub verbosity: Verbosity, + pub should_quit: bool, +} + +impl App { + pub fn new(mut projections: Vec) -> Self { + projections.sort_by(|left, right| left.intent_id.cmp(&right.intent_id)); + Self { + projections, + selected: 0, + detail_open: false, + verbosity: Verbosity::Quiet, + should_quit: false, + } + } + + pub fn projections(&self) -> &[IntentProjection] { + &self.projections + } + + pub fn selected_index(&self) -> usize { + self.selected + } + + pub fn selected_projection(&self) -> Option<&IntentProjection> { + self.projections.get(self.selected) + } + + pub fn visible_activity(&self) -> Vec<&Activity> { + let Some(projection) = self.selected_projection() else { + return Vec::new(); + }; + projection + .recent_activity + .iter() + .filter(|activity| match self.verbosity { + Verbosity::Quiet => activity.meaningful, + Verbosity::Summary => true, + Verbosity::Detailed | Verbosity::Debug => true, + }) + .collect() + } + + pub fn next_intent(&mut self) { + if !self.projections.is_empty() { + self.selected = (self.selected + 1) % self.projections.len(); + } + } + + pub fn previous_intent(&mut self) { + if !self.projections.is_empty() { + self.selected = self + .selected + .checked_sub(1) + .unwrap_or(self.projections.len() - 1); + } + } + + pub fn handle_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Down | KeyCode::Char('j') => self.next_intent(), + KeyCode::Up | KeyCode::Char('k') => self.previous_intent(), + KeyCode::Enter | KeyCode::Char('d') => self.detail_open = !self.detail_open, + KeyCode::Char('v') => self.verbosity = self.verbosity.next(), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{fixtures, projection::Reducer}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn controls_change_only_local_view_state() { + let state = Reducer::new().reduce(fixtures::events_for( + fixtures::FixtureScenario::ActiveHealthy, + )); + let before = state.projections(); + let mut app = App::new(before.clone()); + app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.verbosity, Verbosity::Summary); + assert!(app.detail_open); + assert_eq!(app.selected_projection(), Some(&before[0])); + } + + #[test] + fn quiet_mode_keeps_blocking_activity_visible() { + let state = Reducer::new().reduce(fixtures::events_for( + fixtures::FixtureScenario::ValidationFailure, + )); + let app = App::new(state.projections()); + assert!( + app.visible_activity() + .iter() + .any(|activity| activity.text.contains("failed")) + ); + } +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..0737c8e --- /dev/null +++ b/src/events.rs @@ -0,0 +1,215 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +/// The producer boundary is provisional until Pincher publishes a compatible +/// host contract. `Fixture` is intentionally distinct from a real producer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceType { + Pincher, + Decapod, + Fixture, + Unknown(String), +} + +impl SourceType { + pub fn label(&self) -> &str { + match self { + Self::Pincher => "Pincher", + Self::Decapod => "Decapod", + Self::Fixture => "fixture", + Self::Unknown(value) => value.as_str(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SourceFreshness { + Fresh, + Stale { + age_seconds: u64, + }, + Unavailable { + reason: String, + }, + #[default] + Unknown, +} + +impl SourceFreshness { + pub fn label(&self) -> String { + match self { + Self::Fresh => "Fresh".to_string(), + Self::Stale { age_seconds } => format!("Stale ({age_seconds}s)"), + Self::Unavailable { reason } => format!("Unavailable: {reason}"), + Self::Unknown => "Unknown freshness".to_string(), + } + } +} + +/// A transport-neutral event envelope shaped by the currently available +/// Pincher broker types. Its string event type is intentional: unknown future +/// events must survive projection instead of being discarded by deserialization. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SourceEvent { + pub event_id: String, + pub event_type: String, + pub source_timestamp: String, + pub source_type: SourceType, + #[serde(default)] + pub freshness: SourceFreshness, + pub intent_id: Option, + pub run_id: Option, + pub decapod_session_id: Option, + pub agent_id: Option, + pub todo_id: Option, + pub work_unit_id: Option, + #[serde(default)] + pub payload: Value, +} + +#[derive(Debug, Error)] +pub enum EventParseError { + #[error("invalid source event JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("source event is missing {0}")] + MissingField(&'static str), + #[error("source event has invalid {0}")] + InvalidField(&'static str), +} + +impl SourceEvent { + pub fn new( + event_id: impl Into, + event_type: impl Into, + timestamp: impl Into, + source_type: SourceType, + intent_id: impl Into, + ) -> Self { + Self { + event_id: event_id.into(), + event_type: event_type.into(), + source_timestamp: timestamp.into(), + source_type, + freshness: SourceFreshness::Fresh, + intent_id: Some(intent_id.into()), + run_id: None, + decapod_session_id: None, + agent_id: None, + todo_id: None, + work_unit_id: None, + payload: Value::Object(serde_json::Map::new()), + } + } + + pub fn from_json(input: &str) -> Result { + let event: Self = serde_json::from_str(input)?; + if event.event_id.trim().is_empty() { + return Err(EventParseError::MissingField("event_id")); + } + if event.event_type.trim().is_empty() { + return Err(EventParseError::MissingField("event_type")); + } + if event.source_timestamp.trim().is_empty() { + return Err(EventParseError::MissingField("source_timestamp")); + } + if !timestamp_is_plausible(&event.source_timestamp) { + return Err(EventParseError::InvalidField("source_timestamp")); + } + Ok(event) + } + + pub fn with_payload(mut self, payload: Value) -> Self { + self.payload = payload; + self + } + + pub fn with_run(mut self, run_id: impl Into) -> Self { + self.run_id = Some(run_id.into()); + self + } + + pub fn with_session(mut self, session_id: impl Into) -> Self { + self.decapod_session_id = Some(session_id.into()); + self + } + + pub fn with_agent(mut self, agent_id: impl Into) -> Self { + self.agent_id = Some(agent_id.into()); + self + } + + pub fn with_todo(mut self, todo_id: impl Into) -> Self { + self.todo_id = Some(todo_id.into()); + self + } + + pub fn with_work_unit(mut self, work_unit_id: impl Into) -> Self { + self.work_unit_id = Some(work_unit_id.into()); + self + } + + pub fn with_freshness(mut self, freshness: SourceFreshness) -> Self { + self.freshness = freshness; + self + } + + pub fn payload_string(&self, key: &str) -> Option { + self.payload + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) + } + + pub fn payload_strings(&self, key: &str) -> Vec { + self.payload + .get(key) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() + } +} + +pub(crate) fn timestamp_is_plausible(timestamp: &str) -> bool { + let bytes = timestamp.as_bytes(); + bytes.len() >= 20 + && bytes.get(4) == Some(&b'-') + && bytes.get(7) == Some(&b'-') + && bytes.get(10) == Some(&b'T') + && (timestamp.ends_with('Z') + || timestamp + .bytes() + .skip(19) + .any(|byte| byte == b'+' || byte == b'-')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_external_event_is_rejected_without_guessing_success() { + let error = SourceEvent::from_json( + r#"{"event_type":"ready","source_timestamp":"2026-07-21T00:00:00Z"}"#, + ) + .expect_err("missing event id must fail closed"); + assert!(error.to_string().contains("event_id")); + } + + #[test] + fn malformed_timestamp_is_rejected() { + let input = r#"{"event_id":"e1","event_type":"ready","source_timestamp":"later","source_type":"pincher","intent_id":"i1"}"#; + assert!(matches!( + SourceEvent::from_json(input), + Err(EventParseError::InvalidField("source_timestamp")) + )); + } +} diff --git a/src/fixtures.rs b/src/fixtures.rs new file mode 100644 index 0000000..9323f5f --- /dev/null +++ b/src/fixtures.rs @@ -0,0 +1,224 @@ +use crate::events::{SourceEvent, SourceFreshness, SourceType}; +use serde_json::json; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FixtureScenario { + ActiveHealthy, + AwaitingApproval, + Blocked, + ValidationFailure, + ProofPending, + AuthoritativelyReady, + Failed, + HandedOff, + Stale, + Unavailable, + UnknownFutureEvent, + ConflictingEvidence, +} + +pub fn all_scenarios() -> [FixtureScenario; 12] { + [ + FixtureScenario::ActiveHealthy, + FixtureScenario::AwaitingApproval, + FixtureScenario::Blocked, + FixtureScenario::ValidationFailure, + FixtureScenario::ProofPending, + FixtureScenario::AuthoritativelyReady, + FixtureScenario::Failed, + FixtureScenario::HandedOff, + FixtureScenario::Stale, + FixtureScenario::Unavailable, + FixtureScenario::UnknownFutureEvent, + FixtureScenario::ConflictingEvidence, + ] +} + +pub fn title(scenario: FixtureScenario) -> &'static str { + match scenario { + FixtureScenario::ActiveHealthy => "Refactor workspace custody", + FixtureScenario::AwaitingApproval => "Approve release boundary", + FixtureScenario::Blocked => "Resolve missing workspace", + FixtureScenario::ValidationFailure => "Repair failing validation", + FixtureScenario::ProofPending => "Collect promotion proof", + FixtureScenario::AuthoritativelyReady => "Review completed migration", + FixtureScenario::Failed => "Recover failed handoff", + FixtureScenario::HandedOff => "Review engineering handoff", + FixtureScenario::Stale => "Reconnect stale projection", + FixtureScenario::Unavailable => "Restore authority source", + FixtureScenario::UnknownFutureEvent => "Inspect future event", + FixtureScenario::ConflictingEvidence => "Reconcile evidence conflict", + } +} + +pub fn events_for(scenario: FixtureScenario) -> Vec { + let intent = format!("fixture/{scenario:?}").to_lowercase(); + let title = title(scenario); + let mut events = vec![ + base("001", "intent.created", &intent, SourceType::Fixture) + .with_payload(json!({"title": title})), + base("002", "work.started", &intent, SourceType::Pincher) + .with_run("run-7f3e") + .with_session("session-01J0-AMNION") + .with_agent("agent-pincher-executor") + .with_todo("todo-01J0-REFAC") + .with_work_unit("workunit-01J0-REFAC") + .with_payload(json!({ + "workspace": ".decapod/workspaces/agent-pincher-refac", + "workspace_known": true, + "touched_files": ["src/adapter.rs", "tests/projection.rs"] + })), + ]; + + match scenario { + FixtureScenario::ActiveHealthy => { + events.push(base("003", "work.progress", &intent, SourceType::Pincher)); + } + FixtureScenario::AwaitingApproval => { + events.push( + base("003", "approval.requested", &intent, SourceType::Pincher).with_payload( + json!({ + "approval_id": "interlock-release-7", + "reason": "Promotion changes the protected branch" + }), + ), + ); + } + FixtureScenario::Blocked => { + events.push( + base("003", "blocked", &intent, SourceType::Pincher).with_payload(json!({ + "blocker_id": "custody-missing-2", + "cause": "workspace path was not returned by the source" + })), + ); + } + FixtureScenario::ValidationFailure => { + events.push( + base("003", "validation.failed", &intent, SourceType::Decapod).with_payload( + json!({ + "validation_id": "validation-2026-07-21", + "gate": "projection-contract", + "errors": ["authoritative proof artifact is missing"] + }), + ), + ); + } + FixtureScenario::ProofPending => { + events.push( + base("003", "proof.pending", &intent, SourceType::Pincher).with_payload( + json!({"proof_id": "proof-7f3e", "criteria": "cargo test + decapod validate"}), + ), + ); + } + FixtureScenario::AuthoritativelyReady => { + events.push( + base("003", "validation.passed", &intent, SourceType::Decapod) + .with_payload(json!({"validation_id": "validation-7f3e", "gate": "all"})), + ); + events.push( + base("004", "proof.verified", &intent, SourceType::Decapod) + .with_payload(json!({"proof_id": "proof-7f3e"})), + ); + events.push(base("005", "ready", &intent, SourceType::Decapod)); + } + FixtureScenario::Failed => { + events.push( + base("003", "failed", &intent, SourceType::Pincher).with_payload(json!({ + "cause": "provider process exited before the handoff summary was written" + })), + ); + } + FixtureScenario::HandedOff => { + events.push( + base("003", "handoff.requested", &intent, SourceType::Pincher) + .with_payload(json!({"handoff_id": "handoff-7f3e"})), + ); + events.push( + base("004", "handoff.accepted", &intent, SourceType::Decapod) + .with_payload(json!({"handoff_id": "handoff-7f3e"})), + ); + } + FixtureScenario::Stale => { + events.push( + base("003", "source.stale", &intent, SourceType::Pincher) + .with_freshness(SourceFreshness::Stale { age_seconds: 317 }), + ); + } + FixtureScenario::Unavailable => { + events.push( + base("003", "source.unavailable", &intent, SourceType::Decapod).with_freshness( + SourceFreshness::Unavailable { + reason: "Decapod query timed out".to_string(), + }, + ), + ); + } + FixtureScenario::UnknownFutureEvent => { + events.push( + base("003", "execution.phase.v3", &intent, SourceType::Pincher) + .with_payload(json!({"phase": "review", "confidence": 0.99})), + ); + } + FixtureScenario::ConflictingEvidence => { + events.push( + base("003", "validation.passed", &intent, SourceType::Decapod) + .with_payload(json!({"validation_id": "validation-old", "gate": "all"})), + ); + events.push( + base("004", "validation.failed", &intent, SourceType::Decapod).with_payload( + json!({"validation_id": "validation-new", "errors": ["replay mismatch"]}), + ), + ); + } + } + events +} + +pub fn demo_events() -> Vec { + all_scenarios().into_iter().flat_map(events_for).collect() +} + +fn base(id: &str, event_type: &str, intent: &str, source_type: SourceType) -> SourceEvent { + SourceEvent::new( + format!("evt-{id}-{intent}"), + event_type, + format!("2026-07-21T00:00:{id}Z"), + source_type, + intent, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::projection::{Attention, IntentStatus, Reducer}; + + #[test] + fn every_fixture_projects_without_panicking() { + for scenario in all_scenarios() { + let state = Reducer::new().reduce(events_for(scenario)); + assert_eq!(state.projections().len(), 1, "{scenario:?}"); + } + } + + #[test] + fn fixture_states_cover_attention_and_authority_boundaries() { + let state = Reducer::new().reduce(demo_events()); + let projections = state.projections(); + assert!( + projections + .iter() + .any(|p| p.status == IntentStatus::ReadyForReview) + ); + assert!( + projections + .iter() + .any(|p| p.attention == Attention::Approval) + ); + assert!( + projections + .iter() + .any(|p| p.attention == Attention::SourceConflict) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0023d23 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +//! Amnion is a read-only projection client for Pincher and Decapod state. +//! +//! The projection layer is deliberately independent from terminal rendering and +//! from any future Pincher transport. The fixture source is the only adapter in +//! this first slice; it is not a production protocol declaration. + +pub mod app; +pub mod events; +pub mod fixtures; +pub mod projection; +pub mod sources; +pub mod ui; + +pub use app::{App, Verbosity}; +pub use events::{SourceEvent, SourceFreshness, SourceType}; +pub use projection::{IntentProjection, Reducer}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fb8adaa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,41 @@ +use amnion::{app::App, fixtures, projection::Reducer, ui}; +use crossterm::event::{self, Event}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use std::io; + +fn main() -> Result<(), Box> { + let state = Reducer::new().reduce(fixtures::demo_events()); + let mut app = App::new(state.projections()); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = run(&mut terminal, &mut app); + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + result +} + +fn run( + terminal: &mut Terminal, + app: &mut App, +) -> Result<(), Box> { + while !app.should_quit { + terminal.draw(|frame| ui::draw(frame, app))?; + if event::poll(std::time::Duration::from_millis(250))? + && let Event::Key(key) = event::read()? + { + app.handle_key(key); + } + } + Ok(()) +} diff --git a/src/projection.rs b/src/projection.rs new file mode 100644 index 0000000..4e1e1c2 --- /dev/null +++ b/src/projection.rs @@ -0,0 +1,864 @@ +use crate::events::{SourceEvent, SourceFreshness, SourceType, timestamp_is_plausible}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceState { + #[default] + Absent, + Pending, + Passed, + Failed, + Unavailable, + Conflicting, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Evidence { + pub state: EvidenceState, + pub reference: Option, + pub details: Vec, + pub source: Option, + pub observed_at: Option, +} + +impl Default for Evidence { + fn default() -> Self { + Self { + state: EvidenceState::Absent, + reference: None, + details: Vec::new(), + source: None, + observed_at: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorityState { + NotProvided, + ObservedOnly, + Confirmed, + Unavailable, + Conflicting, +} + +impl AuthorityState { + pub fn label(&self) -> &'static str { + match self { + Self::NotProvided => "No authority evidence", + Self::ObservedOnly => "Observed only", + Self::Confirmed => "Decapod evidence present", + Self::Unavailable => "Authority unavailable", + Self::Conflicting => "Conflicting evidence", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IntentStatus { + Unknown, + Working, + WaitingApproval, + Blocked, + Failed, + ProofPending, + WaitingForEvidence, + ReadyForReview, + HandedOff, + Stale, + Unavailable, + Conflicting, +} + +impl IntentStatus { + pub fn label(&self) -> &'static str { + match self { + Self::Unknown => "Unknown", + Self::Working => "Working", + Self::WaitingApproval => "Waiting for approval", + Self::Blocked => "Blocked", + Self::Failed => "Failed", + Self::ProofPending => "Proof pending", + Self::WaitingForEvidence => "Ready observed; evidence missing", + Self::ReadyForReview => "Ready for review", + Self::HandedOff => "Handed off", + Self::Stale => "Stale source", + Self::Unavailable => "Source unavailable", + Self::Conflicting => "Conflicting evidence", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Attention { + None, + Approval, + Blocked, + ValidationFailure, + ProofFailure, + HandoffDecision, + SourceConflict, + AuthorityUnavailable, + StaleSource, + CustodyMissing, + EvidenceRequired, + UnsupportedSource, +} + +impl Attention { + pub fn label(&self) -> &'static str { + match self { + Self::None => "No action needed", + Self::Approval => "Needs your decision", + Self::Blocked => "Blocked", + Self::ValidationFailure => "Validation failed", + Self::ProofFailure => "Proof failed", + Self::HandoffDecision => "Needs handoff decision", + Self::SourceConflict => "Source conflict", + Self::AuthorityUnavailable => "Authority unavailable", + Self::StaleSource => "Source is stale", + Self::CustodyMissing => "Custody is incomplete", + Self::EvidenceRequired => "Needs authoritative evidence", + Self::UnsupportedSource => "Unsupported source event", + } + } + + pub fn requires_human(&self) -> bool { + !matches!(self, Self::None) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TodoClaimState { + Unknown, + Claimed, + Unclaimed, + Completed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TodoClaim { + pub id: String, + pub state: TodoClaimState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceReference { + pub reference: String, + pub known: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApprovalReference { + pub id: String, + pub state: String, + pub source: SourceType, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Blocker { + pub reference: Option, + pub cause: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Handoff { + pub state: String, + pub reference: Option, + pub authoritative: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Custody { + pub run_id: Option, + pub decapod_session_id: Option, + pub agent_id: Option, + pub todo: Option, + pub work_unit_id: Option, + pub workspace: Option, + pub touched_files: Vec, + pub approvals: Vec, + pub blockers: Vec, + pub validation: Evidence, + pub proof: Evidence, + pub handoff: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObservedEvent { + pub event_id: String, + pub event_type: String, + pub source_timestamp: String, + pub source_type: SourceType, + pub payload: Value, +} + +impl From<&SourceEvent> for ObservedEvent { + fn from(event: &SourceEvent) -> Self { + Self { + event_id: event.event_id.clone(), + event_type: event.event_type.clone(), + source_timestamp: event.source_timestamp.clone(), + source_type: event.source_type.clone(), + payload: event.payload.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Activity { + pub event_id: String, + pub timestamp: String, + pub text: String, + pub meaningful: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct IntentProjection { + pub intent_id: String, + pub title: Option, + pub status: IntentStatus, + pub attention: Attention, + pub authority: AuthorityState, + pub freshness: SourceFreshness, + pub custody: Custody, + pub events: Vec, + pub unknown_events: Vec, + pub diagnostics: Vec, + pub recent_activity: Vec, +} + +impl IntentProjection { + fn new(intent_id: String) -> Self { + Self { + intent_id, + title: None, + status: IntentStatus::Unknown, + attention: Attention::None, + authority: AuthorityState::NotProvided, + freshness: SourceFreshness::Unknown, + custody: Custody::default(), + events: Vec::new(), + unknown_events: Vec::new(), + diagnostics: Vec::new(), + recent_activity: Vec::new(), + } + } + + pub fn has_authoritative_ready_evidence(&self) -> bool { + self.custody.validation.state == EvidenceState::Passed + && self.custody.proof.state == EvidenceState::Passed + && self.authority == AuthorityState::Confirmed + && self.custody.validation.source == Some(SourceType::Decapod) + && self.custody.proof.source == Some(SourceType::Decapod) + && self.attention == Attention::None + } + + pub fn latest_activity(&self) -> Option<&Activity> { + self.recent_activity.last() + } +} + +#[derive(Debug, Default)] +pub struct ProjectionState { + intents: BTreeMap, + seen_event_ids: BTreeSet, + pub unscoped_events: Vec, + pub diagnostics: Vec, +} + +impl ProjectionState { + pub fn projections(&self) -> Vec { + self.intents.values().cloned().collect() + } + + pub fn projection(&self, intent_id: &str) -> Option<&IntentProjection> { + self.intents.get(intent_id) + } + + pub fn seen_event_count(&self) -> usize { + self.seen_event_ids.len() + } +} + +#[derive(Debug, Default)] +pub struct Reducer { + state: ProjectionState, +} + +impl Reducer { + pub fn new() -> Self { + Self::default() + } + + pub fn reduce(mut self, events: impl IntoIterator) -> ProjectionState { + let mut ordered: Vec<_> = events.into_iter().collect(); + ordered.sort_by(|left, right| { + left.source_timestamp + .cmp(&right.source_timestamp) + .then_with(|| left.event_id.cmp(&right.event_id)) + }); + for event in ordered { + self.apply(event); + } + self.state + } + + pub fn apply(&mut self, event: SourceEvent) { + if !self.state.seen_event_ids.insert(event.event_id.clone()) { + return; + } + + let Some(intent_id) = event.intent_id.clone() else { + self.state.unscoped_events.push(ObservedEvent::from(&event)); + self.state.diagnostics.push(format!( + "event {} has no intent_id; preserved outside intent projections", + event.event_id + )); + return; + }; + + let projection = self + .state + .intents + .entry(intent_id) + .or_insert_with_key(|key| IntentProjection::new(key.clone())); + + projection.events.push(ObservedEvent::from(&event)); + projection.freshness = event.freshness.clone(); + copy_custody_ids(projection, &event); + + if !timestamp_is_plausible(&event.source_timestamp) { + projection.unknown_events.push(ObservedEvent::from(&event)); + projection.attention = Attention::UnsupportedSource; + projection.diagnostics.push(format!( + "event {} has malformed source timestamp; state transition ignored", + event.event_id + )); + add_activity( + projection, + &event, + "Malformed source event preserved", + false, + ); + return; + } + + match event.freshness { + SourceFreshness::Stale { .. } => { + projection.status = IntentStatus::Stale; + projection.attention = Attention::StaleSource; + add_activity(projection, &event, "Source became stale", true); + return; + } + SourceFreshness::Unavailable { ref reason } => { + projection.status = IntentStatus::Unavailable; + projection.attention = Attention::AuthorityUnavailable; + projection.authority = AuthorityState::Unavailable; + add_activity( + projection, + &event, + &format!("Source unavailable: {reason}"), + true, + ); + return; + } + SourceFreshness::Fresh | SourceFreshness::Unknown => {} + } + + let event_type = event.event_type.as_str(); + let is_decopod = event.source_type == SourceType::Decapod; + match event_type { + "intent.created" => { + projection.title = event.payload_string("title"); + projection.status = IntentStatus::Unknown; + add_activity(projection, &event, "Intent became visible", true); + } + "agent.started" | "work.started" | "work.progress" => { + projection.status = IntentStatus::Working; + add_activity(projection, &event, "Working", event_type != "work.progress"); + } + "todo.claimed" => { + if let Some(id) = event.todo_id.clone() { + projection.custody.todo = Some(TodoClaim { + id, + state: TodoClaimState::Claimed, + }); + } + projection.status = IntentStatus::Working; + add_activity(projection, &event, "Todo claimed", true); + } + "todo.completed" => { + if let Some(todo) = projection.custody.todo.as_mut() { + todo.state = TodoClaimState::Completed; + } + add_activity(projection, &event, "Todo completed", true); + } + "approval.requested" => { + let approval_id = event + .payload_string("approval_id") + .or_else(|| event.payload_string("interlock_id")); + if let Some(id) = approval_id { + push_approval( + projection, + ApprovalReference { + id, + state: "requested".to_string(), + source: event.source_type.clone(), + }, + ); + } + projection.status = IntentStatus::WaitingApproval; + projection.attention = Attention::Approval; + add_activity(projection, &event, "Waiting for approval", true); + } + "approval.granted" | "approval.denied" => { + let state = event_type.strip_prefix("approval.").unwrap_or("observed"); + if let Some(approval) = projection.custody.approvals.last_mut() { + approval.state = state.to_string(); + } + add_activity(projection, &event, &format!("Approval {state}"), true); + if !is_decopod { + projection.diagnostics.push(format!( + "{} is a local observation and cannot grant authority", + event.event_id + )); + } else if state == "granted" { + projection.attention = Attention::None; + } + } + "blocked" | "interlock.encountered" => { + let cause = event + .payload_string("reason") + .or_else(|| event.payload_string("cause")) + .unwrap_or_else(|| "Blocker cause not provided".to_string()); + projection.custody.blockers.push(Blocker { + reference: event.payload_string("blocker_id"), + cause: cause.clone(), + }); + projection.status = IntentStatus::Blocked; + projection.attention = Attention::Blocked; + add_activity(projection, &event, &format!("Blocked: {cause}"), true); + } + "failed" | "work.failed" | "task.failed" => { + let cause = event + .payload_string("cause") + .or_else(|| event.payload_string("reason")) + .unwrap_or_else(|| "Execution failed".to_string()); + projection.status = IntentStatus::Failed; + projection.attention = Attention::Blocked; + projection.custody.blockers.push(Blocker { + reference: event.payload_string("failure_id"), + cause: cause.clone(), + }); + add_activity(projection, &event, &format!("Failed: {cause}"), true); + } + "validation.passed" | "validation.failed" => { + let state = if event_type.ends_with("passed") { + EvidenceState::Passed + } else { + EvidenceState::Failed + }; + let details = event.payload_strings("errors"); + if projection.custody.validation.state != EvidenceState::Absent + && projection.custody.validation.state != state + { + projection.custody.validation.state = EvidenceState::Conflicting; + projection.authority = AuthorityState::Conflicting; + projection.status = IntentStatus::Conflicting; + projection.attention = Attention::SourceConflict; + projection + .diagnostics + .push("validation evidence conflicts".to_string()); + } else { + projection.custody.validation = Evidence { + state: state.clone(), + reference: event.payload_string("validation_id").or_else(|| { + event + .payload_string("gate") + .map(|gate| format!("gate:{gate}")) + }), + details, + source: Some(event.source_type.clone()), + observed_at: Some(event.source_timestamp.clone()), + }; + if is_decopod { + projection.authority = AuthorityState::Confirmed; + } else { + projection.authority = AuthorityState::ObservedOnly; + } + if state == EvidenceState::Failed { + projection.status = IntentStatus::Failed; + projection.attention = Attention::ValidationFailure; + projection.custody.blockers.push(Blocker { + reference: projection.custody.validation.reference.clone(), + cause: if projection.custody.validation.details.is_empty() { + "Validation failed".to_string() + } else { + projection.custody.validation.details.join("; ") + }, + }); + } + } + let text = if state == EvidenceState::Passed { + "Validation observed" + } else { + "Validation failed" + }; + add_activity(projection, &event, text, true); + } + "proof.pending" => { + projection.custody.proof = Evidence { + state: EvidenceState::Pending, + reference: event.payload_string("proof_id"), + details: Vec::new(), + source: Some(event.source_type.clone()), + observed_at: Some(event.source_timestamp.clone()), + }; + projection.status = IntentStatus::ProofPending; + projection.attention = Attention::None; + add_activity(projection, &event, "Proof pending", true); + } + "proof.passed" | "proof.verified" | "proof.failed" => { + let state = if event_type.ends_with("failed") { + EvidenceState::Failed + } else { + EvidenceState::Passed + }; + if projection.custody.proof.state != EvidenceState::Absent + && projection.custody.proof.state != EvidenceState::Pending + && projection.custody.proof.state != state + { + projection.custody.proof.state = EvidenceState::Conflicting; + projection.authority = AuthorityState::Conflicting; + projection.status = IntentStatus::Conflicting; + projection.attention = Attention::SourceConflict; + } else { + projection.custody.proof = Evidence { + state: state.clone(), + reference: event.payload_string("proof_id"), + details: event.payload_strings("errors"), + source: Some(event.source_type.clone()), + observed_at: Some(event.source_timestamp.clone()), + }; + projection.authority = if is_decopod { + AuthorityState::Confirmed + } else { + AuthorityState::ObservedOnly + }; + if state == EvidenceState::Failed { + projection.status = IntentStatus::Failed; + projection.attention = Attention::ProofFailure; + } + } + add_activity( + projection, + &event, + if state == EvidenceState::Failed { + "Proof failed" + } else { + "Proof verified" + }, + true, + ); + } + "ready" => { + projection.authority = if is_decopod { + AuthorityState::Confirmed + } else { + AuthorityState::ObservedOnly + }; + if projection.has_authoritative_ready_evidence() { + projection.status = IntentStatus::ReadyForReview; + projection.attention = Attention::None; + add_activity(projection, &event, "Ready for review", true); + } else { + projection.status = IntentStatus::WaitingForEvidence; + projection.attention = Attention::EvidenceRequired; + projection.diagnostics.push( + "local ready observation did not assert authoritative completion" + .to_string(), + ); + add_activity(projection, &event, "Ready observed; evidence missing", true); + } + } + "handoff.requested" | "handoff.accepted" | "handoff.rejected" => { + let state = event_type.strip_prefix("handoff.").unwrap_or("observed"); + let authoritative = is_decopod && state != "requested"; + projection.custody.handoff = Some(Handoff { + state: state.to_string(), + reference: event.payload_string("handoff_id"), + authoritative, + }); + projection.status = IntentStatus::HandedOff; + projection.attention = if state == "requested" || !authoritative { + Attention::HandoffDecision + } else { + Attention::None + }; + add_activity(projection, &event, "Handoff requires review", true); + } + "custody.invalid" => { + projection.status = IntentStatus::Blocked; + projection.attention = Attention::CustodyMissing; + projection.custody.blockers.push(Blocker { + reference: event.payload_string("custody_id"), + cause: event + .payload_string("reason") + .unwrap_or_else(|| "Custody is invalid".to_string()), + }); + add_activity(projection, &event, "Custody is incomplete", true); + } + "source.unavailable" => { + projection.status = IntentStatus::Unavailable; + projection.attention = Attention::AuthorityUnavailable; + projection.authority = AuthorityState::Unavailable; + add_activity(projection, &event, "Source unavailable", true); + } + "provider.token" | "tool.started" | "tool.finished" | "command.output" => { + add_activity(projection, &event, "Execution activity", false); + } + _ => { + let observed = ObservedEvent::from(&event); + projection.unknown_events.push(observed); + if projection.status == IntentStatus::Unknown { + projection.attention = Attention::UnsupportedSource; + } + projection.diagnostics.push(format!( + "unknown event type preserved: {}", + event.event_type + )); + add_activity(projection, &event, "Unknown source event preserved", false); + } + } + refresh_derived_state(projection); + } + + pub fn state(&self) -> &ProjectionState { + &self.state + } +} + +fn copy_custody_ids(projection: &mut IntentProjection, event: &SourceEvent) { + if event.run_id.is_some() { + projection.custody.run_id = event.run_id.clone(); + } + if event.decapod_session_id.is_some() { + projection.custody.decapod_session_id = event.decapod_session_id.clone(); + } + if event.agent_id.is_some() { + projection.custody.agent_id = event.agent_id.clone(); + } + if event.todo_id.is_some() && projection.custody.todo.is_none() { + projection.custody.todo = event.todo_id.clone().map(|id| TodoClaim { + id, + state: TodoClaimState::Unknown, + }); + } + if event.work_unit_id.is_some() { + projection.custody.work_unit_id = event.work_unit_id.clone(); + } + if let Some(workspace) = event.payload_string("workspace") { + projection.custody.workspace = Some(WorkspaceReference { + reference: workspace, + known: event + .payload + .get("workspace_known") + .and_then(Value::as_bool) + .unwrap_or(true), + }); + } + let touched = event.payload_strings("touched_files"); + for path in touched { + if !projection.custody.touched_files.contains(&path) { + projection.custody.touched_files.push(path); + } + } +} + +fn push_approval(projection: &mut IntentProjection, approval: ApprovalReference) { + if !projection + .custody + .approvals + .iter() + .any(|existing| existing.id == approval.id) + { + projection.custody.approvals.push(approval); + } +} + +fn add_activity( + projection: &mut IntentProjection, + event: &SourceEvent, + text: &str, + meaningful: bool, +) { + projection.recent_activity.push(Activity { + event_id: event.event_id.clone(), + timestamp: event.source_timestamp.clone(), + text: text.to_string(), + meaningful, + }); + if projection.recent_activity.len() > 20 { + let excess = projection.recent_activity.len() - 20; + projection.recent_activity.drain(0..excess); + } +} + +fn refresh_derived_state(projection: &mut IntentProjection) { + if matches!(projection.freshness, SourceFreshness::Stale { .. }) { + projection.status = IntentStatus::Stale; + projection.attention = Attention::StaleSource; + return; + } + if matches!(projection.freshness, SourceFreshness::Unavailable { .. }) { + projection.status = IntentStatus::Unavailable; + projection.attention = Attention::AuthorityUnavailable; + return; + } + if projection.custody.validation.state == EvidenceState::Conflicting + || projection.custody.proof.state == EvidenceState::Conflicting + { + projection.status = IntentStatus::Conflicting; + projection.attention = Attention::SourceConflict; + projection.authority = AuthorityState::Conflicting; + return; + } + if projection.custody.validation.state == EvidenceState::Failed { + projection.status = IntentStatus::Failed; + projection.attention = Attention::ValidationFailure; + return; + } + if projection.custody.proof.state == EvidenceState::Failed { + projection.status = IntentStatus::Failed; + projection.attention = Attention::ProofFailure; + return; + } + if projection.custody.validation.state == EvidenceState::Passed + && projection.custody.proof.state == EvidenceState::Passed + && projection.authority == AuthorityState::Confirmed + { + projection.status = IntentStatus::ReadyForReview; + projection.attention = Attention::None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::SourceEvent; + use serde_json::json; + + fn event(id: &str, kind: &str, source: SourceType) -> SourceEvent { + SourceEvent::new(id, kind, "2026-07-21T00:00:00Z", source, "intent/test") + } + + #[test] + fn local_ready_does_not_become_authoritative() { + let state = Reducer::new().reduce([event("1", "ready", SourceType::Pincher)]); + let intent = state.projection("intent/test").expect("projection"); + assert_eq!(intent.status, IntentStatus::WaitingForEvidence); + assert_eq!(intent.authority, AuthorityState::ObservedOnly); + assert!(!intent.has_authoritative_ready_evidence()); + } + + #[test] + fn conflicting_validation_is_visible() { + let mut passed = event("1", "validation.passed", SourceType::Decapod); + passed.payload = json!({"validation_id":"v1"}); + let mut failed = event("2", "validation.failed", SourceType::Decapod); + failed.payload = json!({"errors":["gate failed"]}); + let state = Reducer::new().reduce([passed, failed]); + let intent = state.projection("intent/test").expect("projection"); + assert_eq!(intent.status, IntentStatus::Conflicting); + assert_eq!(intent.attention, Attention::SourceConflict); + } + + #[test] + fn replay_is_deterministic_and_duplicate_events_are_idempotent() { + let first = event("2", "work.started", SourceType::Pincher).with_run("run-2"); + let second = event("1", "work.progress", SourceType::Pincher); + let ordered = Reducer::new().reduce([first.clone(), second.clone()]); + let replayed = Reducer::new().reduce([second, first.clone(), first]); + assert_eq!(ordered.projections(), replayed.projections()); + assert_eq!(replayed.seen_event_count(), 2); + } + + #[test] + fn unknown_events_are_preserved_without_becoming_success() { + let state = Reducer::new().reduce([event("1", "future.phase.v4", SourceType::Pincher)]); + let intent = state.projection("intent/test").expect("projection"); + assert_eq!(intent.unknown_events.len(), 1); + assert_ne!(intent.status, IntentStatus::ReadyForReview); + } + + #[test] + fn stale_and_unavailable_sources_remain_visible() { + let stale = event("1", "source.stale", SourceType::Pincher) + .with_freshness(SourceFreshness::Stale { age_seconds: 9 }); + let unavailable = event("2", "source.unavailable", SourceType::Decapod).with_freshness( + SourceFreshness::Unavailable { + reason: "timeout".to_string(), + }, + ); + let stale_state = Reducer::new().reduce([stale]); + let unavailable_state = Reducer::new().reduce([unavailable]); + assert_eq!( + stale_state + .projection("intent/test") + .expect("projection") + .status, + IntentStatus::Stale + ); + assert_eq!( + unavailable_state + .projection("intent/test") + .expect("projection") + .authority, + AuthorityState::Unavailable + ); + } + + #[test] + fn custody_ids_survive_projection() { + let event = event("1", "work.started", SourceType::Pincher) + .with_run("run-1") + .with_session("session-1") + .with_agent("agent-1") + .with_todo("todo-1") + .with_work_unit("workunit-1"); + let state = Reducer::new().reduce([event]); + let custody = &state.projection("intent/test").expect("projection").custody; + assert_eq!(custody.run_id.as_deref(), Some("run-1")); + assert_eq!(custody.decapod_session_id.as_deref(), Some("session-1")); + assert_eq!(custody.agent_id.as_deref(), Some("agent-1")); + assert_eq!( + custody.todo.as_ref().map(|todo| todo.id.as_str()), + Some("todo-1") + ); + assert_eq!(custody.work_unit_id.as_deref(), Some("workunit-1")); + } + + #[test] + fn failure_causes_remain_inspectable() { + let mut failed = event("1", "failed", SourceType::Pincher); + failed.payload = json!({"cause":"provider exited"}); + let state = Reducer::new().reduce([failed]); + let intent = state.projection("intent/test").expect("projection"); + assert_eq!(intent.status, IntentStatus::Failed); + assert!( + intent + .custody + .blockers + .iter() + .any(|blocker| blocker.cause == "provider exited") + ); + } +} diff --git a/src/sources.rs b/src/sources.rs new file mode 100644 index 0000000..f72541c --- /dev/null +++ b/src/sources.rs @@ -0,0 +1,36 @@ +use crate::events::SourceEvent; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SourceError { + #[error("source unavailable: {0}")] + Unavailable(String), +} + +/// The adapter boundary is intentionally small. A future Pincher transport can +/// implement this trait without changing the reducer, app state, or UI. +pub trait EventSourceAdapter { + fn name(&self) -> &str; + fn load(&self) -> Result, SourceError>; +} + +#[derive(Debug, Clone)] +pub struct FixtureSource { + pub scenario: crate::fixtures::FixtureScenario, +} + +impl FixtureSource { + pub fn new(scenario: crate::fixtures::FixtureScenario) -> Self { + Self { scenario } + } +} + +impl EventSourceAdapter for FixtureSource { + fn name(&self) -> &str { + "fixture/replay (provisional)" + } + + fn load(&self) -> Result, SourceError> { + Ok(crate::fixtures::events_for(self.scenario)) + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..8d8d55c --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,323 @@ +use crate::app::{App, Verbosity}; +use crate::projection::{Attention, IntentProjection}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; + +pub fn draw(frame: &mut Frame<'_>, app: &App) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(6), + Constraint::Length(2), + ]) + .split(frame.area()); + + let header = Paragraph::new(Line::from(vec![ + Span::styled( + " AMNION ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" governed work, quietly visible"), + ])) + .block(Block::default().borders(Borders::BOTTOM)); + frame.render_widget(header, outer[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(34), Constraint::Min(35)]) + .split(outer[1]); + draw_intent_list(frame, app, body[0]); + draw_detail(frame, app, body[1]); + + let footer = Paragraph::new(Line::from(vec![ + Span::styled("↑/↓", Style::default().fg(Color::Cyan)), + Span::raw(" intents "), + Span::styled("Enter/d", Style::default().fg(Color::Cyan)), + Span::raw(" detail "), + Span::styled("v", Style::default().fg(Color::Cyan)), + Span::raw(" verbosity "), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(" quit"), + ])) + .block(Block::default().borders(Borders::TOP)); + frame.render_widget(footer, outer[2]); +} + +fn draw_intent_list(frame: &mut Frame<'_>, app: &App, area: Rect) { + let items = app + .projections() + .iter() + .enumerate() + .map(|(index, projection)| { + let marker = if projection.attention.requires_human() { + "!" + } else { + "·" + }; + let name = projection.title.as_deref().unwrap_or(&projection.intent_id); + let text = format!( + "{marker} {}\n {}", + sanitize(name), + projection.status.label() + ); + let style = if index == app.selected_index() { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else if projection.attention.requires_human() { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + ListItem::new(text).style(style) + }) + .collect::>(); + let list = List::new(items) + .block(Block::default().title(" Intents ").borders(Borders::ALL)) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + frame.render_widget(list, area); +} + +fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(projection) = app.selected_projection() else { + frame.render_widget( + Paragraph::new("No governed intents available").block( + Block::default() + .title(" Selected intent ") + .borders(Borders::ALL), + ), + area, + ); + return; + }; + + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(7), Constraint::Min(5)]) + .split(area); + let summary = summary_lines(projection, app.verbosity); + frame.render_widget( + Paragraph::new(Text::from(summary)) + .block( + Block::default() + .title(" Custody spine ") + .borders(Borders::ALL), + ) + .wrap(Wrap { trim: true }), + vertical[0], + ); + + let mut lines = app + .visible_activity() + .into_iter() + .map(|activity| { + let prefix = if activity.meaningful { "•" } else { "·" }; + Line::from(format!( + "{prefix} {} {}", + activity.timestamp, + sanitize(&activity.text) + )) + }) + .collect::>(); + if app.detail_open { + lines.extend(detail_lines(projection, app.verbosity)); + } else if lines.is_empty() { + lines.push(Line::from( + "No meaningful activity yet. Press Enter for detail.", + )); + } + let title = format!( + " Activity · {} · {} ", + app.verbosity.label(), + if app.detail_open { "open" } else { "quiet" } + ); + frame.render_widget( + Paragraph::new(Text::from(lines)) + .block(Block::default().title(title).borders(Borders::ALL)) + .wrap(Wrap { trim: true }), + vertical[1], + ); +} + +fn summary_lines(projection: &IntentProjection, verbosity: Verbosity) -> Vec> { + let name = projection.title.as_deref().unwrap_or("Untitled intent"); + let todo = projection + .custody + .todo + .as_ref() + .map(|todo| format!("{} ({:?})", todo.id, todo.state)) + .unwrap_or_else(|| "Unknown".to_string()); + let workspace = projection + .custody + .workspace + .as_ref() + .map(|workspace| workspace.reference.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + vec![ + Line::from(vec![ + Span::styled( + sanitize(name), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + status_span(projection), + ]), + Line::from(format!("Attention: {}", projection.attention.label())), + Line::from(format!("Authority: {}", projection.authority.label())), + Line::from(format!("Freshness: {}", projection.freshness.label())), + Line::from(format!( + "Todo: {todo} Workspace: {}", + sanitize(&workspace) + )), + Line::from(format!( + "Validation: {:?} Proof: {:?}", + projection.custody.validation.state, projection.custody.proof.state + )), + if verbosity == Verbosity::Quiet { + Line::from("Press Enter to inspect custody, evidence, and source events.") + } else { + Line::from(format!( + "Custody: run={:?} session={:?} work-unit={:?}", + projection.custody.run_id, + projection.custody.decapod_session_id, + projection.custody.work_unit_id + )) + }, + ] +} + +fn status_span(projection: &IntentProjection) -> Span<'static> { + let color = match projection.attention { + Attention::None => Color::Green, + Attention::Approval | Attention::EvidenceRequired | Attention::HandoffDecision => { + Color::Yellow + } + Attention::StaleSource | Attention::AuthorityUnavailable => Color::Magenta, + _ => Color::Red, + }; + Span::styled( + projection.status.label().to_string(), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) +} + +fn detail_lines(projection: &IntentProjection, verbosity: Verbosity) -> Vec> { + let mut lines = vec![ + Line::from(format!("Intent ID: {}", sanitize(&projection.intent_id))), + Line::from(format!( + "Touched files: {}", + if projection.custody.touched_files.is_empty() { + "Unknown".to_string() + } else { + projection + .custody + .touched_files + .iter() + .map(|path| sanitize(path)) + .collect::>() + .join(", ") + } + )), + Line::from(format!( + "Approvals: {}", + projection + .custody + .approvals + .iter() + .map(|approval| format!("{} [{}]", sanitize(&approval.id), approval.state)) + .collect::>() + .join(", ") + .if_empty(|| "None".to_string()) + )), + Line::from(format!( + "Blockers: {}", + projection + .custody + .blockers + .iter() + .map(|blocker| sanitize(&blocker.cause)) + .collect::>() + .join("; ") + .if_empty(|| "None".to_string()) + )), + Line::from(format!( + "Diagnostics: {}", + projection + .diagnostics + .iter() + .map(|diagnostic| sanitize(diagnostic)) + .collect::>() + .join("; ") + .if_empty(|| "None".to_string()) + )), + Line::from(format!( + "Unknown events preserved: {}", + projection.unknown_events.len() + )), + ]; + if matches!(verbosity, Verbosity::Detailed | Verbosity::Debug) { + lines.extend(projection.events.iter().map(|event| { + Line::from(format!( + "event {} · {} · {}", + sanitize(&event.event_id), + sanitize(&event.event_type), + event.source_type.label() + )) + })); + } + if verbosity == Verbosity::Debug { + lines.extend(projection.unknown_events.iter().map(|event| { + Line::from(format!( + "raw {}: {}", + sanitize(&event.event_id), + sanitize(&event.payload.to_string()) + )) + })); + } + lines +} + +fn sanitize(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_control() || matches!(character, '\n' | '\t')) + .collect() +} + +trait EmptyFallback { + fn if_empty(self, fallback: impl FnOnce() -> String) -> String; +} + +impl EmptyFallback for String { + fn if_empty(self, fallback: impl FnOnce() -> String) -> String { + if self.is_empty() { fallback() } else { self } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{fixtures, projection::Reducer}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + #[test] + fn fixture_scenarios_render_without_panic() { + for scenario in fixtures::all_scenarios() { + let state = Reducer::new().reduce(fixtures::events_for(scenario)); + let app = App::new(state.projections()); + let backend = TestBackend::new(100, 40); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| draw(frame, &app)).expect("draw"); + } + } + + #[test] + fn malformed_text_is_stripped_before_rendering() { + assert_eq!(sanitize("safe\u{1b}[31m text"), "safe[31m text"); + } +} diff --git a/tests/projection_replay.rs b/tests/projection_replay.rs new file mode 100644 index 0000000..0ac6dd2 --- /dev/null +++ b/tests/projection_replay.rs @@ -0,0 +1,27 @@ +use amnion::fixtures::{self, FixtureScenario}; +use amnion::projection::{AuthorityState, IntentStatus, Reducer}; + +#[test] +fn the_fixture_adapter_exercises_each_contract_state() { + for scenario in fixtures::all_scenarios() { + let projection = Reducer::new() + .reduce(fixtures::events_for(scenario)) + .projections() + .pop() + .expect("fixture projection"); + assert!(!projection.intent_id.is_empty()); + assert!(!projection.events.is_empty(), "{scenario:?}"); + } +} + +#[test] +fn authoritative_ready_requires_decappod_validation_and_proof() { + let ready = Reducer::new() + .reduce(fixtures::events_for(FixtureScenario::AuthoritativelyReady)) + .projections() + .pop() + .expect("ready projection"); + assert_eq!(ready.status, IntentStatus::ReadyForReview); + assert_eq!(ready.authority, AuthorityState::Confirmed); + assert!(ready.has_authoritative_ready_evidence()); +}