diff --git a/README.md b/README.md index 89a739df..dc6009f4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Run Codex, Claude Code, GitHub Copilot CLI, and future coding harnesses inside e Launch, observe, attach, and coordinate agent work through one neutral runtime substrate. [![MIT License](https://img.shields.io/badge/license-MIT-9A8ECD?style=flat-square)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows%20x64-9A8ECD?style=flat-square)](#requirements) +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows%20x64-9A8ECD?style=flat-square)](https://docs.opencoven.ai/docs/guide/platforms) [![npm](https://img.shields.io/badge/npm-%40opencoven%2Fcli-9A8ECD?style=flat-square)](https://www.npmjs.com/package/@opencoven/cli) [![Built with Rust](https://img.shields.io/badge/built%20with-Rust-9A8ECD?style=flat-square)](https://www.rust-lang.org/) @@ -30,159 +30,27 @@ Launch, observe, attach, and coordinate agent work through one neutral runtime s --- -## Table of Contents - -- [What is Coven?](#what-is-coven) -- [Why Coven?](#why-coven) -- [Features](#features) -- [Requirements](#requirements) -- [Install](#install) -- [Quick Start](#quick-start) -- [Commands Reference](#commands-reference) -- [Local API](#local-api) -- [Architecture](#architecture) -- [Repository Structure](#repository-structure) -- [Configuration](#configuration) -- [OpenCoven Integrations](#opencoven-integrations) -- [Documentation](#documentation) -- [FAQ](#faq) -- [Troubleshooting](#troubleshooting) -- [Contributing](#contributing) -- [Code of Conduct](#code-of-conduct) -- [Roadmap](#roadmap) -- [Security](#security) -- [License](#license) -- [Community & Support](#community--support) - ---- - ## What is Coven? Coven is the local harness substrate for the [OpenCoven](https://github.com/OpenCoven) ecosystem. It gives coding-agent CLIs like [Codex](https://github.com/openai/codex) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code) a shared room where project work can happen visibly and safely. > **One project. Any harness. Visible work.** -Coven doesn't replace your coding agent, your UI, or other clients. It acts as a neutral runtime layer: - - **You choose the harness** — Codex, Claude Code, GitHub Copilot CLI, or future adapters. - **Coven owns the session** — project-scoped boundaries, PTY execution, event logging, SQLite persistence. - **Clients present the work** — CastCodes, the CLI/TUI, comux, or your own integration over the same-user local IPC API. -The Rust daemon is the authority boundary. All clients — including the CLI itself — are convenience layers. Security decisions flow inward to the daemon, never outward to clients. - ---- - -## Why Coven? - -| Without Coven | With Coven | -| --------------------------------------------------- | ----------------------------------------------------------- | -| Run `codex` directly; no persistent session history | Every run creates a session record with metadata and events | -| No project boundary enforcement | Agent is locked to an explicit project root; cannot escape | -| Lose track of agent work when the terminal closes | Sessions persist across daemon restarts via SQLite | -| Manually juggle multiple harness CLIs | One unified `coven run` entry point for all harnesses | -| No API for clients to consume agent sessions | Versioned `coven.daemon.v1` same-user local IPC API for all clients | -| No standard way to observe or replay past work | `coven sessions` browser with Rejoin, View Log, and Archive | - ---- - -## Features - -- **🏠 Project-root boundaries** — Every launch is tied to an explicit repository or project root. The daemon rejects working directories that escape the declared boundary. -- **🔌 Harness-neutral runtime** — bundled support stays focused on Codex, Claude Code, and GitHub Copilot CLI; trusted opt-in recipes cover Hermes and OpenCode, while Grok Build is an experimental opt-in recipe. -- **🖥️ Interactive session browser** — Live and completed work can be selected, rejoined, viewed, archived, or restored without memorizing IDs; eligible non-running, unadopted work can also be sacrificed. -- **📡 Attachable PTY sessions** — Live sessions can be replayed or followed from explicit CLI verbs. -- **🔌 Local daemon API** — CastCodes, comux, and the OpenClaw plugin coordinate through one versioned local IPC contract (`coven.daemon.v1`). -- **🗄️ SQLite-backed history** — Session metadata and event logs survive daemon restarts. -- **🦀 Rust authority layer** — Launch, cwd, input, kill, and path-sensitive requests are revalidated in Rust. Clients are never the trust boundary. -- **🔒 External OpenClaw bridge** — `@opencoven/coven` is an opt-in plugin; OpenClaw core does not include Coven code. -- **📦 @opencoven namespace** — CLI wrapper packages live under `@opencoven/*`; the user-facing command is always `coven`. -- **🩺 System diagnostics** — `coven pc` (macOS-first) surfaces CPU, memory, disk, and process health without launching a harness. - ---- - -## Requirements - -| Requirement | Notes | -| ---------------------------- | --------------------------------------------------------------------------- | -| **Rust stable toolchain** | Required only when building from source | -| **Git** | Required | -| **macOS arm64/x64, glibc-based Linux x64, or Windows x64** | Native npm packages are available for these targets | -| **Node.js 18+** | Required for the npm wrapper; `coven memory open` requires Node.js 24+ | -| **At least one harness CLI** | Codex, Claude Code, and/or GitHub Copilot CLI (see below) | - -### Installing harness CLIs - -Run `coven doctor` first — it reports local readiness and points missing -harnesses to `coven setup`. Doctor stays offline and does not verify provider -authentication. - -**Codex (OpenAI):** - -```bash -npm install -g @openai/codex -# or: brew install --cask codex -codex login -``` - -**Claude Code (Anthropic):** - -```bash -npm install -g @anthropic-ai/claude-code -claude auth login -``` - -**GitHub Copilot CLI (GitHub):** - -```bash -npm install -g @github/copilot -# or: brew install --cask copilot-cli -copilot login -``` - -The recommended guided path is `coven setup codex`, `coven setup claude`, or -`coven setup copilot`; `coven setup all` processes all three in order. Add -`--verify` for a separately consented provider turn, or use `--verify-only` -after an existing login. See the -[`coven setup` reference](docs/reference/cli-setup.md). - -After setup, run `coven doctor` again to confirm the harness is detected. If -Doctor still reports it missing, ensure the harness binary is on your `PATH`. +The Rust daemon is the authority boundary. All clients — including the CLI itself — are convenience layers. Security decisions flow inward to the daemon, never outward to clients. OpenClaw integrates only through the opt-in `@opencoven/coven` plugin in `packages/openclaw-coven`; OpenClaw core contains no Coven code. --- ## Install -Coven is available as an npm wrapper for the fastest install, or you can build from source. - -### npm (recommended) - -Install globally: - ```bash npm install -g @opencoven/cli coven doctor ``` -The memory dashboard is an opt-in companion on its own release train, so a CLI -install stays thin and never pulls the dashboard's application dependencies: - -```bash -npm install -g @opencoven/coven-memory-dashboard -coven memory open -``` - -`coven memory open` starts or reuses the installed local Coven daemon before -launching that companion. It does not require a checkout or running development -server from the `coven-memory` repository. Without the companion installed it -prints the install command above and exits; every other Coven command is -unaffected. - -The core npm wrapper supports Node.js 18 or newer. The memory dashboard -requires Node.js 24 or newer; on an older runtime, only `coven memory open` is -blocked and prints an upgrade instruction. - -**Available npm packages:** - | Package | Platform | | -------------------------- | ---------------------------------------------- | | `@opencoven/cli` | Universal wrapper — auto-selects your platform | @@ -190,41 +58,17 @@ blocked and prints an upgrade instruction. | `@opencoven/cli-macos-x64` | macOS Intel x64 | | `@opencoven/cli-linux-x64` | glibc-based Linux x64 (Alpine unsupported) | | `@opencoven/cli-windows` | Windows x64 | -| `@opencoven/coven-memory-dashboard` | Opt-in loopback memory dashboard companion (installed separately) | -### Build from source (recommended for contributors) +The memory dashboard is an opt-in companion installed separately with +`npm install -g @opencoven/coven-memory-dashboard` (Node.js 24+); see +[`docs/reference/cli-observe.md`](docs/reference/cli-observe.md). -```bash -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build --workspace -cargo run -p coven-cli -- doctor -``` - -> **Note:** Building from source requires Rust stable. See [Requirements](#requirements). +Install routes (npm, cargo, source), platform behavior, service managers, and +containers are documented at **https://docs.opencoven.ai/docs/guide/install**. --- -## Quick Start - -### Option A — Interactive UI (recommended for new users) - -```bash -cd /path/to/your/project -coven -# or explicitly: -coven chat -``` - -Bare `coven` opens the interactive Coven UI, powered by the **Coven engine**. -The first time you run it, `coven` offers to download and install the engine -automatically (or install it anytime with `coven engine install`); it's a -version-pinned, checksum-verified binary that `coven` manages for you — there's -no separate package to install. You can also pass a task directly — -`coven "fix the failing tests"` — and Coven will show a plan card and run it in -a recorded session. - -### Option B — Direct commands +## Quick start ```bash cd /path/to/your/project @@ -240,8 +84,6 @@ coven daemon start # 4. Launch a session coven run codex "fix the failing tests" -# or with Claude Code: -coven run claude "polish this UI" # 5. Browse and manage sessions coven sessions @@ -250,294 +92,9 @@ coven sessions coven daemon stop ``` -### Option C — OpenClaw rescue loop - -If OpenClaw breaks, Coven provides a predictable repair room: - -```bash -coven patch openclaw -``` - -Choose a repo, choose a harness, get a verified patch. - ---- - -## Commands Reference - -The canonical command reference lives at -[docs.opencoven.ai/docs/cli](https://docs.opencoven.ai/docs/cli). The core -verbs: - -| Command | Action | Details | -| --- | --- | --- | -| `coven` / `coven chat` | Open the interactive Coven UI (engine auto-installed on first run); `coven ""` plans and runs a free-text task | [Interactive UI](https://docs.opencoven.ai/docs/cli/interactive) | -| `coven doctor` | Detect supported harness CLIs and print install hints | [Doctor](https://docs.opencoven.ai/docs/cli/doctor) | -| `coven setup []` | Run provider-owned login and optional explicitly consented verification | [Setup](docs/reference/cli-setup.md) | -| `coven daemon start/status/restart/stop` | Manage the local daemon | [Daemon commands](https://docs.opencoven.ai/docs/cli/daemon) | -| `coven run ` | Launch a project-scoped harness session (`--cwd`, `--title`, `--model`, `--continue`, `--stream-json`, …) | [Run](https://docs.opencoven.ai/docs/cli/run) | -| `coven sessions` | Browse, search, and inspect sessions (`--plain`, `--json`, `--all`, `search`, `show`, `events`, `log`) | [Sessions](https://docs.opencoven.ai/docs/cli/sessions) | -| `coven attach ` | Replay/follow session output and forward input | [Sessions](https://docs.opencoven.ai/docs/cli/sessions#attach) | -| `coven archive` / `summon` / `sacrifice` | Session rituals (see below) | [Sessions](https://docs.opencoven.ai/docs/cli/sessions) | -| `coven kill` | Stop a live session on Unix-like hosts; Windows-capable integrations request `POST /api/v1/sessions/:id/kill` through daemon local IPC | [CLI reference](https://docs.opencoven.ai/docs/cli) | -| `coven adapter list/doctor/install` | Inspect harness adapters; opt into trusted adapter recipes (e.g. `coven adapter install grok`) | [Repository workflow](https://docs.opencoven.ai/docs/cli/repo-workflow) | -| `coven status` / `familiars` / `skills` / `research` / `calls` / `hub` | Read-only observability with `--json`, mirroring the daemon API routes | [Observability](https://docs.opencoven.ai/docs/cli/observe) | -| `coven memory` / `coven memory --json` / `coven memory open` | Preserve the memory list output or launch the private loopback dashboard | [Memory](https://docs.opencoven.ai/docs/memory-models) | -| `coven memory import` / `coven memory restore` | Preview, apply, verify, and logically restore one familiar's memory migration | [Memory](https://docs.opencoven.ai/docs/memory-models) | -| `coven wt` / `claim` / `hooks` | Parallel work protocol: worktrees, TTL-bounded claims, git hooks | [Repository workflow](https://docs.opencoven.ai/docs/cli/repo-workflow) | -| `coven pc` | macOS-first system diagnostics; write operations require `--confirm` | [PC diagnostics](https://docs.opencoven.ai/docs/cli/pc) | -| `coven patch openclaw` / `logs prune` / `vacuum` | OpenClaw rescue loop, log-retention pruning, store repair | [CLI reference](https://docs.opencoven.ai/docs/cli) | -| `coven completions ` | Generate shell completions (bash, zsh, fish, elvish, powershell) | [CLI reference](https://docs.opencoven.ai/docs/cli) | - -> Session-id arguments (`attach`, `summon`, `archive`, `sacrifice`, `kill`, and -> the `sessions show/events/log` subcommands) accept a unique prefix of the id -> (e.g. `coven attach 9099`), so you don't have to paste full UUIDs. - -> **Session rituals are intentionally explicit.** Archive is reversible and keeps the full event ledger. Summon brings an archived session back. Sacrifice is destructive, applies only to eligible non-running sessions without adopted/reserved evidence, and requires `--yes`. - -| Ritual | Reversible? | Works on | Description | -| ------------- | ----------- | -------------------- | ------------------------------------------------------------ | -| **Archive** | ✅ Yes | Non-running sessions | Hides from active list; all events preserved | -| **Summon** | N/A | Archived sessions | Restores to active list | -| **Sacrifice** | ❌ No | Eligible non-running, unadopted sessions | Permanently deletes session and all events; adopted/reserved evidence is retained; requires `--yes` | -| **Rejoin** | N/A | Live sessions | Reattaches to running session | - ---- - -## Local API - -The daemon exposes a versioned HTTP API over same-user local IPC. On Unix-like -hosts, this is `/coven.sock`; on Windows, it is an owner-only named -pipe selected by `COVEN_HOME`. Windows clients discover the fully qualified -pipe path as `state.daemon_ipc` from `coven config paths --json`; they must not -construct a pipe name from the Unix convention or use health/status transport -metadata as a named-pipe path. The current public contract is -`coven.daemon.v1` (prefix: `/api/v1`). - -The public API guide and interactive endpoint reference live at -[docs.opencoven.ai/docs/reference/api](https://docs.opencoven.ai/docs/reference/api). -[`docs/API-CONTRACT.md`](docs/API-CONTRACT.md) remains beside the code as the -normative versioned source contract: shapes, error codes, cursor pagination, -and compatibility rules. - -### Recommended client handshake - -All API clients should start with a health negotiation: - -```bash -# Unix-like example: health check via Unix socket -curl --unix-socket ~/.coven/coven.sock http://localhost/api/v1/health -``` - -**Before depending on any other endpoint:** - -1. Call `GET /api/v1/health` -2. Verify `apiVersion === "coven.daemon.v1"` and `capabilities.structuredErrors === true` -3. Check `capabilities.sessions === true` before using session endpoints and - `capabilities.events === true` before reading events -4. Check `capabilities.eventCursor === "sequence"` before using `afterSeq` pagination -5. Only then depend on the documented `v1` sessions/events shapes - -All API errors use a structured `{ "error": { "code", "message", "details" } }` envelope. Branch on `error.code`, never on `error.message`. - -Local dashboards use `GET /api/v1/memory`, `GET /api/v1/memory/overview`, -and `GET /api/v1/memory/:id`. The daemon resolves and validates memory files; -clients must not open the archival database, vector index, manifest, or memory -paths directly. - -`coven memory open` establishes local daemon readiness before delegating to the -installed `@opencoven/coven-memory-dashboard` executable. The npm wrapper -supplies only the installed Node executable and dashboard entrypoint; memory -data and daemon transport proofs are never passed through the environment. -The companion is not a dependency of the wrapper; install it globally with -`npm install -g @opencoven/coven-memory-dashboard`, which also puts -`coven-memory-dashboard` on `PATH` for direct native binary installs. The -dashboard requires Node.js 24 or newer; the rest of the npm-wrapped CLI remains -available on Node.js 18 or newer. - -Treat the local IPC API as the product contract. Clients may validate for better UX, but the Rust daemon remains the authority boundary. - ---- - -## Architecture - -### Runtime topology - -Coven is a local-first harness substrate. The Rust daemon is the authority boundary. All clients — including the CLI/TUI — are untrusted for enforcement purposes. - -``` -Developer - │ - ├── CastCodes workspace ─────────────────────┐ - ├── coven CLI / TUI ─────────────────────────┤ HTTP over local IPC - ├── comux (legacy/reference) ────────────────┤ local IPC API - └── @opencoven/coven (OpenClaw plugin) ───────┘ - │ - ┌───────────────▼──────────────────┐ - │ Coven Rust Daemon │ - │ │ - │ ┌───────────────────────────┐ │ - │ │ Authority boundary │ │ - │ │ • Canonicalize project root│ │ - │ │ • Validate cwd in root │ │ - │ │ • Allowlist harness id │ │ - │ │ • Validate session state │ │ - │ │ • Route action via policy │ │ - │ └────────────┬──────────────┘ │ - │ │ │ - │ ┌────────────▼─────────────┐ │ - │ │ Harness adapter router │ │ - │ └───────┬──────────────┬───┘ │ - │ │ │ │ - │ ┌───▼──┐ ┌───▼───┐ │ - │ │Codex │ │Claude │ │ - │ │ PTY │ │ PTY │ │ - │ └───┬──┘ └───┬───┘ │ - │ │ │ │ - │ ┌───────▼──────────────▼──────┐ │ - │ │ SQLite session ledger + │ │ - │ │ append-only event log │ │ - │ └──────────────────────────────┘ │ - └───────────────────────────────────┘ -``` - -### Authority boundary - -The Rust daemon validates every request before acting: - -1. `projectRoot` must be explicit — no fallback -2. `cwd` must canonicalize inside the declared project root -3. Harness ID must be allowlisted (`codex`, `claude`, `copilot`) -4. Session IDs must exist and be in the expected state -5. All harness commands are built with argv APIs — never `sh -c` - -Clients may improve UX by validating early, but they are never the enforcement boundary. A client cannot widen the project boundary, bypass the harness allowlist, or escape session state validation. - -### Session lifecycle - -``` -coven run codex "fix tests" - │ - ▼ -POST /api/v1/sessions { projectRoot, cwd, harness, prompt } - │ - ▼ -Daemon: canonicalize → validate → spawn or reject - │ - ▼ -Session record created in SQLite - │ - ▼ -Harness spawned in PTY → output events streamed to SQLite - │ - ▼ -coven sessions → Rejoin / View Log / Archive / Sacrifice -``` - -For full architecture diagrams (including Mermaid flow charts), see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). - ---- - -## Repository Structure - -``` -coven/ -├── .github/ # GitHub Actions workflows, issue templates -├── assets/opencoven/ # Project assets (logos, icons for npm packages) -├── brand/ # OpenCoven brand system -│ ├── icons/ # Brand icon set (trident, agent-node, etc.) -│ ├── social/ # Social media assets (X, GitHub) -│ └── ui/ # CSS color tokens and typography scale -├── crates/ -│ ├── coven-cli/ # Main Rust binary — the `coven` command -│ └── coven-relay/ # Internal relay crate -├── docs/ # Source-adjacent contracts, maintainer docs, plans, and historical records -├── npm/ # npm wrapper package source for @opencoven/cli -├── packages/ -│ └── openclaw-coven/ # External OpenClaw bridge plugin (@opencoven/coven) -├── scripts/ -│ └── check-secrets.py # CI / pre-release secret scanner -├── skills/opencoven-design/ # Design skill files -├── web/ # Web surface files -├── Cargo.lock # Locked Rust dependency tree -├── Cargo.toml # Rust workspace manifest -├── CONTRIBUTING.md # Contribution guidelines -├── DESIGN.md # Full brand and design system reference -├── LICENSE # MIT license -├── README.md # This file -└── SECURITY.md # Security policy -``` - -**Key directories:** - -- **`crates/coven-cli`** — Everything that becomes the `coven` binary. This is where daemon, PTY adapter, session store, local IPC API, and CLI surface live in Rust. -- **`packages/openclaw-coven`** — The opt-in bridge between OpenClaw and Coven. Lives here (not in OpenClaw core) to keep the trust boundary clean. Published as `@opencoven/coven`. -- **`scripts/check-secrets.py`** — Required pre-release and pre-PR scan. Run it before pushing to avoid leaking credentials into git history. -- **`docs/`** — Source-adjacent contracts, maintainer/development guidance, - implementation plans, and historical records. Public user documentation is - canonical at [docs.opencoven.ai](https://docs.opencoven.ai/). - ---- - -## Configuration - -Coven works with zero configuration. State lives under `COVEN_HOME` (default `~/.coven`); privacy and retention knobs are optional. The deep detail lives in the docs: - -| Surface | What it controls | Reference | -| ------------------------------------ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| `COVEN_HOME` | Root directory for daemon state: SQLite database, same-user local IPC, logs, encryption keys | [Daemon configuration](https://docs.opencoven.ai/docs/daemon/configuration) | -| Daemon environment and `privacy.toml` | Raw-artifact persistence (`COVEN_PERSIST_RAW_ARTIFACTS`), retention windows, redaction | [Daemon configuration](https://docs.opencoven.ai/docs/daemon/configuration) | -| `~/.config/coven/settings.json` | CLI settings under `covenCli.*`: repo registry, privacy keys, fuzzy paths | [`docs/SETTINGS.md`](docs/SETTINGS.md) | - -> **Tip:** If you run Coven in CI or need isolated environments, set `COVEN_HOME` to a unique path per environment. Coven will create the directory if it doesn't exist. - -Retention defaults (30 days for redacted event logs, 7 days for optional raw encrypted artifacts) and manual pruning via `coven logs prune` are covered in [`docs/reference/cli-logs.md`](docs/reference/cli-logs.md). - -For debugging local state without deleting an entire profile, use `coven reset` -to preview selected familiar, project, GitHub/Copilot, or runtime-adapter state, -plus secret, cache, session, or metadata state. Runtime -selectors cover Coven-local Claude, OpenClaw, Hermes, OpenCode, Grok Build, and -Gemini records only; mobile gateway state has its own selector. After -`coven daemon stop` and after other active Coven commands finish, Unix -`--apply` moves only the selected state into `COVEN_HOME/reset-backups/`. -Windows currently supports preview only. Reset never changes a provider CLI, -login, or account, and project reset never deletes a checkout. -Session reset keeps encrypted artifact records and their key together. See -[`docs/reference/cli-reset.md`](docs/reference/cli-reset.md). - -Never commit runtime state: `.coven/`, `*.sqlite*`, `*.db`, `*.sock`, `.env*`, and `*.key` are covered by `.gitignore`. Before submitting any PR, run the secret scanner (`python scripts/check-secrets.py`) — see [Security](#security). - ---- - -## OpenCoven Integrations - -Coven is the runtime layer. Other surfaces in the OpenCoven ecosystem sit above -it and connect through same-user local IPC: a Unix socket at -`COVEN_HOME/coven.sock` on Unix-like hosts or an owner-only named pipe on -Windows, whose fully qualified client path is `state.daemon_ipc` from -`coven config paths --json`. - -| Integration | Role | How it connects | -| -------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------- | -| **[CastCodes](https://github.com/OpenCoven/cast-codes)** | Primary public workspace; the local-first AI coding product built on Coven | HTTP over same-user local IPC | -| **comux** | Legacy terminal cockpit (useful reference; not the future public story) | HTTP over same-user local IPC | -| **OpenClaw** | External coding agent; integrates via opt-in plugin only | `@opencoven/coven` plugin → local IPC | - -> **Important:** OpenClaw core does not contain Coven code. The integration lives exclusively in `packages/openclaw-coven` and publishes as `@opencoven/coven`. This separation keeps the trust boundary clean — the plugin is treated as an untrusted socket client, and the Rust daemon revalidates every request it makes. - -### CastCodes - -CastCodes is the primary product users open: terminal/code workspace, visible agent lanes, review flows, and approval UX. It is the first-contact public story for Coven. - -The intended flow is: - -``` -User → CastCodes → coven run → Coven daemon → Harness PTY -Harness output → Coven event log → CastCodes session view -``` - -### comux (legacy reference) - -comux is a standalone terminal cockpit that proved the tmux-cockpit model for parallel agent work. Its useful primitives (worktree isolation, pane menus, agent launcher registry) are being folded into CastCodes-native concepts. comux is no longer the future-facing public surface. +Bare `coven` opens the interactive Coven UI instead — see +[Interactive UI](https://docs.opencoven.ai/docs/cli/interactive). The command +reference lives at [docs.opencoven.ai/docs/cli](https://docs.opencoven.ai/docs/cli). --- @@ -545,6 +102,15 @@ comux is a standalone terminal cockpit that proved the tmux-cockpit model for pa Public installation, CLI, daemon, harness, API, memory, and troubleshooting documentation is canonical at **[docs.opencoven.ai](https://docs.opencoven.ai/)**. +Start with: + +- [Getting started](https://docs.opencoven.ai/docs/guide/getting-started) +- [CLI reference](https://docs.opencoven.ai/docs/cli) +- [Daemon](https://docs.opencoven.ai/docs/daemon) +- [Harnesses](https://docs.opencoven.ai/docs/harnesses) +- [Local API](https://docs.opencoven.ai/docs/reference/api) +- [Memory](https://docs.opencoven.ai/docs/memory-models) +- [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) — or run `coven doctor` first This repository keeps only documentation that must evolve with the source: @@ -560,267 +126,24 @@ This repository keeps only documentation that must evolve with the source: --- -## FAQ - -**Q: What is Coven, exactly?** - -Coven is a local Rust daemon and CLI that supervises coding-agent CLI sessions -(like Codex or Claude Code) inside explicit project boundaries, records -everything to SQLite, and exposes it through a versioned HTTP API over -same-user local IPC: a Unix socket at `COVEN_HOME/coven.sock` on Unix-like -hosts or an owner-only named pipe on Windows. - -**Q: Does Coven replace Codex or Claude Code?** - -No. Coven wraps them. You still use the harness CLI for its AI capabilities — Coven adds project-scoped boundaries, session persistence, and a unified API on top. - -**Q: Does Coven require an internet connection or an account?** - -Core Coven operation and `coven doctor` are local. Your harness CLIs require -their own provider authentication and network access. Coven stores no provider -credentials; only an explicitly consented `coven setup --verify` or a harness -session launches a provider turn. - -**Q: Is Windows supported?** - -Yes. `@opencoven/cli-windows` ships a native Windows x64 binary, and the universal `@opencoven/cli` wrapper selects it automatically. Run `coven doctor` from the same PowerShell, Windows Terminal, or WSL2 environment where your harness CLI is installed. - -**Q: What is `coven pc`?** - -A macOS-first system diagnostics and relief tool built into the CLI. It shows CPU, memory, disk, and process health without launching a harness — useful when sessions feel slow or the daemon is sluggish to start. All read operations are side-effect-free. Write operations (kill, cache clear) require an explicit `--confirm` flag and cannot be bypassed. - -**Q: What does "Sacrifice" mean?** - -Sacrifice is Coven's intentionally explicit verb for permanently deleting an eligible non-running session and all its event history. It requires `--yes`. Sessions with adopted or historical reserved evidence are retained; O3 defines no retention/fence release. Archive + Summon are the reversible alternatives. - -**Q: What is `COVEN_HOME`?** - -The directory where Coven stores all local state: SQLite database, same-user -local IPC, logs, and encryption keys. On Unix-like hosts, IPC uses -`COVEN_HOME/coven.sock`; on Windows, it uses an owner-only named pipe. -Defaults to `~/.coven`. To isolate environments (e.g., in CI), set -`COVEN_HOME` to a separate path for each environment. - -**Q: Is CastCodes the same as Coven?** - -No. CastCodes is a separate product — the local-first AI coding workspace and primary public-facing product that runs on top of Coven. Coven is the runtime substrate. CastCodes is the workspace you open. - -**Q: What is the relationship with OpenClaw?** - -OpenClaw is an external coding agent that can optionally integrate with Coven through the `@opencoven/coven` plugin package. OpenClaw core contains no Coven code. The integration is strictly opt-in and requires installing the plugin separately. - -**Q: Can I build my own client on top of Coven?** - -Yes. The daemon exposes a stable `coven.daemon.v1` HTTP API over same-user -local IPC: a Unix socket at `COVEN_HOME/coven.sock` on Unix-like hosts or an -owner-only named pipe on Windows. Windows clients discover the fully qualified -pipe path as `state.daemon_ipc` from `coven config paths --json`. All clients -are untrusted for enforcement, but the API surface is stable and versioned. -See [`docs/API-CONTRACT.md`](docs/API-CONTRACT.md) and -[`docs/CLIENT-INTEGRATION.md`](docs/CLIENT-INTEGRATION.md). - -**Q: What if I want to add a new harness (like Aider or Gemini)?** - -See [`docs/HARNESS-ADAPTERS.md`](docs/HARNESS-ADAPTERS.md) for the adapter contract. The supported set is Codex, Claude Code, and GitHub Copilot CLI — new harnesses are planned for later milestones after adapter contracts are stable. - ---- - -## Troubleshooting - -The fastest first step for any broken setup: - -```bash -coven doctor -``` - -`coven doctor` checks store readiness, project detection, daemon status, and harness availability — and prints specific next steps for every failure branch. - -### Quick reference - -| Symptom | First step | -| ------------------------------------------- | -------------------------------------------------------------------------------------- | -| `coven: command not found` | Run `npm install -g @opencoven/cli`; verify binary is on `PATH` | -| `doctor` reports missing harness | Install and authenticate the harness CLI (see [Requirements](#requirements)) | -| Daemon won't start | Run `coven daemon restart`; check `$COVEN_HOME` ownership and permissions | -| Session browser shows a table, not a UI | Terminal isn't interactive; use `coven sessions --manage` to force the browser | -| `cwd` rejected at launch | The working directory resolves outside the project root; use a path inside it | -| Stale "running" sessions after daemon crash | Run `coven daemon restart` to mark dead sessions `orphaned`, then archive them; sacrifice is only for eligible rows without adopted/reserved evidence | -| Sessions feel slow / daemon sluggish | Run `coven pc status` to check system pressure; `coven pc top --n 10` for CPU culprits | -| `coven attach` won't accept input | The session is not live; attach replays logs for completed or archived sessions | -| Secret scan fails | Remove the secret from your working tree; rotate it if it entered git history | -| Local familiar/project/integration state is broken | Preview `coven reset --list-features`, then reset only the affected local category with `--apply` | -| API version mismatch | Update Coven to match the client's expected contract, or update the client | - -For the full diagnostic flowchart and detailed resolution steps, see -[Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting). - ---- - ## Contributing -> **Contribution Status — Updated July 2026** -> -> External Pull Requests are open. Please start from an issue for larger changes -> and include the readiness packet requested by the PR template. - -### First 10 minutes (source checkout) - -```bash -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build --workspace -cargo run -p coven-cli -- doctor -cargo test -p coven-cli --test smoke -- --nocapture -``` - -A healthy first pass: the workspace builds, `doctor` prints setup status, and the smoke test passes. The smoke test uses an isolated temporary `COVEN_HOME` and injects a fake `codex` binary into `PATH` — it does not require real harness credentials or a network connection. - -### Local development loop - -```bash -# Build -cargo build --workspace - -# Rust checks (required before any PR) -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace --locked - -# Secret scanner (required before any PR) -python scripts/check-secrets.py +See [CONTRIBUTING.md](CONTRIBUTING.md) for the first-10-minutes checkout path, +the full local development loop, and the PR readiness packet. The short rules: -# Smoke test (required for daemon/session/attach/ritual changes) -cargo test -p coven-cli --test smoke -- --nocapture - -# Manual smoke run — use a throwaway project, not a real repository -cargo run -p coven-cli -- daemon start -cargo run -p coven-cli -- run codex "say hello from coven" -cargo run -p coven-cli -- sessions -cargo run -p coven-cli -- daemon stop -``` +- **Rust is the authority layer.** Launch, cwd validation, PTY lifecycle, session persistence, and IPC enforcement are Rust's responsibility; clients are never the trust boundary. +- **Keep harness support focused** on Codex, Claude Code, and GitHub Copilot CLI until adapter contracts are stable. +- **Run `python scripts/check-secrets.py` before every PR**, including docs-only changes, and never commit runtime state (`.coven/`, `*.sqlite*`, `*.sock`, `.env*`, `*.key`). -### CLI performance baselines - -Build the native binary first, then run the isolated benchmark fixture: +Performance baselines collect trend data without gating merges: ```bash cargo build -p coven-cli --locked node scripts/benchmark-cli.mjs --binary target/debug/coven --iterations 3 --output /tmp/coven-perf.json -cargo test -p coven-cli --bin coven tui::chat::events::tests::benchmark_schedule_metrics_emit_json --locked -- --ignored --nocapture -``` - -The runner uses disposable `COVEN_HOME` directories, a fixture-only fake Codex -executable, and the same-user local IPC API. It records command startup, cold daemon -start-to-health, daemon session-listing, event-tail, and harness-first-output -timings without reading real configuration, prompts, or session logs. Each cold -start sample gets a fresh home and a matching daemon stop. The ignored Rust test -prints deterministic TUI poll/draw counters. These outputs are trend data: CI -uploads them as artifacts and validates fixture construction, but does not fail -pull requests on wall-clock thresholds. Cave's managed-start contract retains -its 8-second hard deadline; benchmark p50/p95/p99 values do not replace that -product-level timeout. - -### Concurrent runtime baseline - -Run the complementary concurrent-session baseline before making a throughput, -storage, or cancellation optimization: - -```bash -cargo build -p coven-cli --locked node scripts/benchmark-chaos.mjs --binary target/debug/coven --output /tmp/coven-chaos.json ``` -The command always exercises 1, 8, and 32 concurrent deterministic harness -sessions, records launch-to-first-output percentiles, throughput, cancellation -to-terminal latency, SQLite file growth, writer connection/transaction deltas, -maximum sampled writer backlog, and sampled daemon RSS, and writes a redacted -JSON report. The writer queue values are periodic samples rather than -daemon-maintained high-water marks. Writer measurements come from the live -health contract; RSS is resolved by the exact daemon PID through -`coven pc top --json`, without retaining process names or command lines. Cave -owns the slow-WebSocket-consumer lane in #4317. - -Unsafe host-level faults use deterministic equivalents rather than filling a -real disk or killing an unrelated process: the report names the exact Rust -regressions for the free-disk watermark, real SQLite lock/retry behavior, and -persisted-session crash recovery. These coverage entries remain separate from -trend measurements, so a timing artifact cannot be mistaken for a passing -failure-path test. - -CI runs this step as `continue-on-error`, so it collects trend data without -gating merges — matching how the rest of this section describes these -baselines. The deterministic fixture tests (`benchmark-chaos.test.mjs`) still -gate. On timeout, the collector reports fixture execution count, session/event -state, and a bounded event-writer health snapshot. The event writer retries -only transient SQLite busy/locked commit failures before latching a permanent -failure; unrelated persistence errors remain fail-fast. - -### Architecture rules for contributors - -- **Rust is the authority layer.** Process launch, cwd/project-root validation, PTY lifecycle, session persistence, and local IPC request enforcement are all Rust's responsibility. TypeScript clients improve UX but are never the trust boundary. -- **All clients are untrusted for enforcement** — this includes comux and the OpenClaw plugin. -- **Keep harness support focused.** Supported harnesses are Codex, Claude Code, and GitHub Copilot CLI only until adapter contracts are stable. -- **OpenClaw separation.** Do not place Coven code in OpenClaw core. The integration belongs in `packages/openclaw-coven` as `@opencoven/coven`. -- **No future orchestration commands as user-facing** until they exist in the CLI and local IPC API. - -### Documentation rules - -- Use **OpenCoven** for the ecosystem and organization. Use **Coven** for the CLI and daemon product. -- The user-facing command is always `coven` — never `opencoven` or `@opencoven` in user-facing documentation. -- Use canonical community references: `discord.gg/opencoven` and `@OpenCvn`. -- Use placeholders in all examples: `/path/to/project`, `/Users/example`, `session-1`, `intent-1`. -- Run `python scripts/check-secrets.py` before submitting any PR, including docs-only changes. -- Update docs whenever command behavior, API behavior, or trust boundaries change. - -### Maintainer release checklist - -```bash -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace --locked -python scripts/check-secrets.py -# For package releases: verify package contents with dry run, attach checksums for native binaries -``` - -See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development loop, release checklist, and documentation standards. - ---- - -## Code of Conduct - -OpenCoven is committed to building a welcoming, respectful community where people of all backgrounds and experience levels can contribute and learn. - -**We expect all contributors and community members to:** - -- Be respectful and kind in all interactions — issues, PRs, Discord, and X -- Focus criticism on ideas and code, not people -- Welcome newcomers and answer questions with patience -- Assume good faith before assuming bad - -**We do not tolerate:** - -- Harassment, discrimination, or abuse in any form -- Personal attacks or derogatory language -- Sustained or repeated disruptive behavior - -To report unacceptable behavior, contact the maintainers privately through GitHub or Discord. Reports will be handled with discretion. - ---- - -## Roadmap - -> **Last updated: May 2026.** See [`docs/ROADMAP.md`](docs/ROADMAP.md) for detailed milestone checklists with individual items. - -| Status | Milestone | Summary | -| --------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| ✅ **Shipped** | A: Local runtime foundation | `coven` CLI, Rust daemon, PTY sessions, SQLite ledger, versioned `coven.daemon.v1` API, Codex + Claude adapters, npm packages | -| 🔄 **Now** | B: CastCodes workspace | CastCodes as primary public workspace; Cast Agent + Coven integration direction | -| 🔄 **Now** | C: Community transparency | Public roadmap, Discord update cadence, public issue board | -| 📋 **Next** | D: Harness expansion | Generic command adapter from real usage, third harness proof, compatibility docs | -| 🔬 **Next/Lab** | E: Visible lane → verify → review | CastCodes-native agent lanes, live session display, verification gates, explicit PR/merge workflow | -| 🔭 **Later** | F: Multi-harness orchestration | Handoff protocol, capability routing, multi-instance coordination, audit dashboard (Phases 1–4) | - -The roadmap is written as a community-facing progress ledger, not an internal promise sheet. Items move when they are designed, implemented, tested, and released. Dates are avoided unless a release is already scheduled. +Both use disposable `COVEN_HOME` directories and a fixture-only fake harness; `scripts/benchmark-chaos.test.mjs` gates deterministically in CI. --- @@ -829,34 +152,21 @@ The roadmap is written as a community-facing progress ledger, not an internal pr Coven is pre-1.0 software. Treat it accordingly: - **Do not run untrusted harnesses or prompts in sensitive repositories.** Session logs capture harness output; if the harness dumps secrets, Coven logs them. -- **Do not commit runtime state.** `.coven/`, `*.sqlite`, `*.sock`, `.env*` files, and encryption keys should never enter source control. - **Do not paste secrets into prompts.** Event payloads are redacted before API display, but defense in depth starts with not having secrets in prompts. -**Reporting vulnerabilities:** Please use [GitHub Security Advisories](https://github.com/OpenCoven/coven/security/advisories) for this repository. If advisories are unavailable, contact the maintainer privately. Do not post exploit details in public issues. - -See [SECURITY.md](SECURITY.md) for vulnerability reporting and -[Safety](https://docs.opencoven.ai/docs/reference/safety) for the public trust -boundary and local access model. +**Reporting vulnerabilities:** Please use [GitHub Security Advisories](https://github.com/OpenCoven/coven/security/advisories) for this repository. See [SECURITY.md](SECURITY.md) for the policy and [Safety](https://docs.opencoven.ai/docs/reference/safety) for the public trust boundary. --- -## License +## Roadmap -MIT © Valentina Alexander and the OpenCoven contributors — see [LICENSE](LICENSE) for full terms. +The milestone ledger lives in [`docs/ROADMAP.md`](docs/ROADMAP.md); items move when they are designed, implemented, tested, and released. --- -## Community & Support - -| Channel | Link | -| ----------------------- | ---------------------------------------------------------- | -| 🌐 Website | [opencoven.ai](https://opencoven.ai/) | -| 📝 Feedback | [feedback.opencoven.ai](https://feedback.opencoven.ai/) | -| 💬 Discord | [discord.gg/opencoven](https://discord.gg/opencoven) | -| 🐦 X / Twitter | [@OpenCvn](https://x.com/OpenCvn) | -| 🐛 Issues & Bug Reports | [GitHub Issues](https://github.com/OpenCoven/coven/issues) | -| 📖 Documentation | [docs.opencoven.ai](https://docs.opencoven.ai/) | -| 🗺️ Public Roadmap | [docs/ROADMAP.md](docs/ROADMAP.md) | +## License + +MIT © Valentina Alexander and the OpenCoven contributors — see [LICENSE](LICENSE) for full terms. --- diff --git a/SECURITY.md b/SECURITY.md index 4ae7b701..874229bc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,114 +1,225 @@ # Security Policy -Coven is an early local-first harness substrate for project-scoped coding-agent sessions. -Please treat the repository as pre-1.0 software and avoid running untrusted harnesses or prompts in sensitive repositories. - -OpenClaw integration is externalized through the `@opencoven/coven` plugin. OpenClaw core is not part of Coven's trust root; the plugin should be treated as a local socket client, and the Rust daemon must continue validating launch paths, harness ids, input, and kill requests before acting. - -## Reporting vulnerabilities - -Please report suspected vulnerabilities privately through GitHub Security Advisories for this repository. -If advisories are unavailable, contact the maintainer privately and avoid posting exploit details in public issues. - -## Local data and credentials - -Coven should not require repository-stored secrets. Runtime state belongs outside source control: - -- `.coven/` -- `*.sqlite`, `*.sqlite3`, `*.db` -- `*.sock` -- `.env*` files -- private keys and certificates - -The CI secret guard scans both the current tree and git history for common token/key patterns without printing matched values. - -### Coven privacy guard - -As of 2026-07-26, Coven uses two explicit scanning tiers: - -1. `scripts/check-secrets.py` scans the current tree and full git history for - classic credentials, private keys, and high-entropy secret material. -2. `scripts/check-coven-privacy.py` fails closed on newly staged and - pull-request-changed files containing private session identifiers, messenger - IDs, absolute home paths, runtime-internal paths, phone numbers, or - invite/handoff URLs containing tokens. Managed hooks installed by - `coven hooks install` run this guard before commits, and CI is the - authoritative enforcement layer. - -The second tier intentionally applies to new changes while the repository's -legacy path examples are inventoried and converted to placeholders. This is a -documented baseline, not a claim that historical commits satisfy the newer -privacy rules. Rewriting public history requires explicit maintainer approval. +Coven is an early local-first harness substrate for project-scoped coding-agent +sessions: a small Rust authority layer that launches supported harness CLIs +inside explicit local project boundaries, plus TypeScript integration packages +around it. This is pre-1.0 software. This document is the single normative +security policy for the `OpenCoven/coven` repository. It separates what is +**enforced today**, what is a **residual risk**, and what remains a **design +goal**. + +> Scope note: this policy covers Coven the runtime/daemon/CLI and the code in +> this repository. Organization-wide OpenCoven reporting (protocol, memory +> substrate, other repositories) belongs in the +> [organization-level security policy](https://github.com/OpenCoven/.github/blob/main/SECURITY.md). +> The canonical public overviews live at +> [docs.opencoven.ai](https://docs.opencoven.ai/docs/reference/safety); this +> file stays beside the code as the source-adjacent contract. + +## 1. Supported surfaces and security status + +**Supported release family.** Security fixes land on the current minor release +line published in +[repository releases](https://github.com/OpenCoven/coven/releases) (v0.4.x as +of this update). Coven has no long-term-support commitment before 1.0; run the +latest release to pick up security fixes. + +**Security-supported surfaces.** + +- The Rust daemon authority boundary and its versioned local API, + `coven.daemon.v1`, over same-user local IPC. See the + [local API contract](docs/API-CONTRACT.md) and + [authentication and local access](docs/AUTH.md). +- The bundled CLI and daemon lifecycle surfaces that drive the same boundary + (see [README.md](README.md) and the + [safety model](docs/SAFETY-MODEL.md)). +- Local session state: the SQLite event store, default event/log redaction, and + artifact persistence defaults. See the + [session artifacts spec](specs/coven-session-artifacts/TECH.md) and the + [trust layer contract](specs/coven-trust-layer/PRODUCT.md). +- Repository content guards: the secret scan and the Coven privacy guard run in + CI (`Policy guard`) and in managed local hooks. + +**Experimental or disabled surfaces — not security-supported.** + +- **AgentFS NFS mount backend.** The `coven-afs` storage engine is shipped and + conformance-tested, but every mount backend sits behind the opt-in `mount` + cargo feature and remains a spike. Loopback access control and single-writer + SQLite remain open gates in + [`specs/coven-agent-fs/MOUNT-SPIKE.md`](specs/coven-agent-fs/MOUNT-SPIKE.md), + and the mount surface does not leave experimental status until the + dedicated end-to-end certification gate (#779) passes. Do not expose a + Coven AFS export beyond the local machine. +- **OpenClaw bridge plugin.** Disabled by default; it must be explicitly + selected as the ACP backend. OpenClaw core is not a Coven trust root, and + the plugin's client-side socket validation is defense in depth, not the + enforcement boundary. See [authentication and local access](docs/AUTH.md). +- **Remote and tunnel transports.** The daemon does not bind TCP by default + and has no remote authentication design yet. Only the documented remote + access paths are supported; do not proxy the raw local IPC endpoint into a + network or browser surface. A separate authenticated remote listener is + drafted but unshipped (#463). + +**Same-user trust is not sandboxing.** Coven's boundary assumes the operating +system separates users and that the person running `coven` controls the +machine. It distinguishes two different threats: + +- *Same-user local trust* — what Coven relies on: OS-enforced local IPC + permissions (a private Unix socket or owner-only named pipe) plus same-user + process locality. +- *Sandboxing against hostile local processes, prompts, or providers* — what + Coven does **not** provide. Harnesses run with your user's privileges. A + malicious prompt, harness output, or provider response can steer a harness + within those privileges; the daemon's checks validate requests against the + local API contract, they do not contain a running harness. Never run + untrusted harnesses or prompts in sensitive repositories. + +Coven makes no absolute containment claim (such as "cannot escape") for any +surface. Where a property is enforced, it is tied to the named contracts and +verification families in the next section. + +## 2. Enforced properties today + +Each property below is backed by a normative source-adjacent contract and a +verification family. This table is the whole list; anything documented only as +a draft spec or design goal is in +[Design goals vs guarantees](#5-design-goals-vs-guarantees). + +| Property | Normative contract | Verification | +|---|---|---| +| Rust-owned validation is authoritative over untrusted clients; every sensitive request is revalidated at the daemon and fails closed on unknown versions, action ids, harnesses, and session ids | [Safety model — trust boundary](docs/SAFETY-MODEL.md), [Authentication — Rust authority checks](docs/AUTH.md) | Rust workspace test suites (`cargo test --workspace`) run in CI on every PR | +| Capability advertisement never grants permission: `/api/v1/health` capabilities describe availability only, and clients must still pass every per-operation check | [API contract](docs/API-CONTRACT.md) (`Capabilities advertise availability and never grant permission`) | Health-negotiation contract tests (`crates/coven-client/tests/health.rs`) and daemon contract tests | +| Project, path, and session checks happen before effects: canonicalized `projectRoot`/`cwd`, allowlisted harness ids, live-session validation, and argv-only launch (never `sh -c`) | [Safety model — core rules](docs/SAFETY-MODEL.md), [API contract — error envelopes and fail-closed routes](docs/API-CONTRACT.md) | Rust workspace test suites, including daemon lifecycle and harness parity tests | +| Owner-protected local transport and peer negotiation: the daemon API travels only over same-user local IPC; the bundled Rust client discovers only the current user's private socket/pipe, binds health negotiation to a transport peer fingerprint, and never auto-replays a mutation | [Authentication and local access](docs/AUTH.md), [API contract — reusable Rust client](docs/API-CONTRACT.md) | Client transport and negotiation tests (`crates/coven-client/tests/health.rs`), Windows daemon lifecycle tests | +| Event/log redaction and sensitive-artifact defaults: event payloads are redacted before they are stored or returned by the API; raw sensitive artifact persistence is opt-in, off by default, and encrypted at rest with a private per-home key file | [Trust layer contract — defaults that must hold](specs/coven-trust-layer/PRODUCT.md), [Session artifacts spec](specs/coven-session-artifacts/TECH.md) | Redaction unit tests (`crates/coven-cli/src/privacy.rs`) and artifact store tests in the Rust workspace | +| Secret and privacy guards with a stated baseline: the secret scan covers the full tree and git history; the Coven privacy guard fails closed on new and PR-changed files; CI is the authoritative enforcement layer | [`scripts/check-secrets.py`](scripts/check-secrets.py), [`scripts/check-coven-privacy.py`](scripts/check-coven-privacy.py) | CI `Policy guard` job; managed hooks from `coven hooks install` | +| Mutation replay is explicit, never implicit: adopted launch/input operations use a normative replay-before-mutable ordering with exact first-adoption and exact-replay responses, and retained ambiguity is surfaced instead of silently resolved | [API contract — request ordering and durable side effects](docs/API-CONTRACT.md) | Adopted-route contract tests in the Rust workspace | + +The privacy guard is deliberately a **baseline for new changes**: it applies to +newly staged and PR-changed files while the repository's legacy path examples +are inventoried and converted to placeholders. It rejects sensitive examples, +including invite/handoff URLs containing tokens. It does not claim that +historical commits satisfy the newer privacy rules, and rewriting public +history requires explicit maintainer approval. Memory-layer code, tests, documentation, and PR discussion must describe memory -shape without including real memory content. Use synthetic placeholders such -as `FAMILIAR_ROOT`, ``, and `01JEXAMPLE...`; never copy real +shape without including real memory content. Use synthetic placeholders such as +`FAMILIAR_ROOT`, ``, and `01JEXAMPLE...`; never copy real attestation prose, session identifiers, chat IDs, or local workspace paths into the repository. -## Session logs and sensitive artifacts - -Coven treats session logs, prompts, harness output, tool payloads, and event history as sensitive local data. Do not place secrets in prompts or session context. - -Default session event payloads are redacted before they are stored in SQLite or returned from `/events`, `/sessions/:id/events`, or `/sessions/:id/log`. Redaction covers common authorization headers, cookies, provider token shapes, private key blocks, secret-like `.env` assignments, private gateway URLs, and configured extra patterns. - -Raw sensitive artifact persistence is disabled by default. If `privacy.toml` sets `persist_raw_artifacts = true` or `COVEN_PERSIST_RAW_ARTIFACTS=1`, Coven stores raw payload artifacts separately from normal logs using authenticated local encryption. The encryption key is generated under `/keys/session-artifacts.key` with private file permissions and is not stored in the repository or SQLite database. - -The local key-file provider is an MVP for local-first encryption. It protects raw artifact rows from casual database inspection, but it is not a replacement for OS keychain-backed key management on shared or higher-risk machines. - -Default retention is short for raw encrypted artifacts and bounded for operational logs: - -- Raw encrypted artifacts: 7 days. -- Redacted event logs: 30 days. -- Manual pruning: `coven logs prune`. - ---- - -## OpenCoven Security Disclosure Addendum - -## Security Policy - -### Reporting a Vulnerability - -If you discover a security vulnerability in OpenCoven, please report it responsibly. +## 3. Residual risk and safe configuration + +**What local-first does not protect against.** Local-first keeps data on your +machine and keeps the API off the network by default; it is not a defense +against software already running as your user, harness processes acting with +your privileges, or a hostile prompt/provider steering a harness. It also does +not yet harden daemon-side `COVEN_HOME` ownership and permission checks before +creating or removing daemon state; that remains a documented hardening priority +(see [authentication — current hardening gap](docs/AUTH.md)), so client-side +socket validation should be treated as defense in depth, not a complete +boundary. + +**Raw-artifact opt-in risk.** Setting `persist_raw_artifacts = true` in +`privacy.toml` (or `COVEN_PERSIST_RAW_ARTIFACTS=1`) stores unredacted payload +artifacts. They are encrypted at rest with a key generated under +`/keys/session-artifacts.key` with private file permissions, and +the key is not stored in the repository or the database. The local key-file +provider is an MVP for local-first encryption: it protects raw artifact rows +from casual database inspection, but it is not OS keychain-backed key +management and is not intended for shared or higher-risk machines. + +**Retention is data minimization, not secure deletion.** Raw encrypted +artifacts are retained for 7 days and redacted event logs for 30 days by +default, with manual pruning via `coven logs prune`. Retention bounds how long +sensitive rows persist in Coven's store; it does not overwrite database pages +or other copies your operating system or backup tooling may hold. + +**Untrusted harnesses and prompts.** Supported harness CLIs (Codex, Claude +Code, GitHub Copilot CLI; opt-in recipes beyond that set) execute with your +user's privileges inside the project you point them at. Coven validates how +they are launched and which sessions they reach; it does not police what a +running harness does inside those privileges. Do not paste secrets into +prompts, do not ask a harness to dump environment variables, and use throwaway +projects for demos and smoke tests. + +**AgentFS mount safe configuration.** Treat every AFS mount as experimental +scratch state for a single user on a single machine: loopback only, no +multi-user export, no exposure beyond localhost, and no use as durable +storage. The backend is feature-gated and uncertified; its remaining gates and +go/no-go status are tracked in +[`specs/coven-agent-fs/MOUNT-SPIKE.md`](specs/coven-agent-fs/MOUNT-SPIKE.md) +and the certification work under #779, which this section follows. If the +mount surface ships for real, this policy is updated before that release. + +**Targets and design goals are not enforced properties.** Performance targets, +SLOs, and architectural goals — in this repository or the wider OpenCoven +protocol work — are not security properties of Coven until a corresponding +test suite and release prove them, and release-gating security claims are +recorded in the shipped-truth/certification evidence produced by the release +governance work (#779, #805). + +## 4. Reporting a vulnerability **Do not open a public GitHub issue for security vulnerabilities.** -Contact the maintainers directly: -- Discord: https://discord.gg/OpenCoven (DM @BunsDev) -- Or open a GitHub Security Advisory on the repository - -We will acknowledge receipt within 48 hours and aim to address confirmed vulnerabilities within 14 days. - -### Scope - -Security reports are welcome for: -- OpenCoven core harness and routing logic -- OpenTrust memory and session substrate -- Authentication and identity handling -- Agent sandbox and execution boundaries -- Any mechanism that could allow one agent or user to access another's context - -### Out of Scope - -- Issues in third-party dependencies (report to the dependency maintainer) -- Issues in model provider APIs (report to the provider) - -### Our Commitment - -We take security seriously because OpenCoven handles personal context and agent execution on behalf of users. We will credit researchers who responsibly disclose vulnerabilities (with their permission). - ---- - -## Architectural Security Properties - -The following properties are design goals of OpenCoven. If you find a way to violate them, that's a security report: - -1. **Session isolation** — one user's agent context must not be accessible to another user or agent without explicit permission -2. **Memory ownership** — a user's stored memory and context must remain under their control -3. **Agent identity integrity** — a familiar's identity must not be forgeable by another agent or external caller -4. **Execution boundaries** — agent tool calls must not escape their intended scope - ---- - -*Last updated: 2026-07-26* +- **Primary path:** open a private + [GitHub Security Advisory](https://github.com/OpenCoven/coven/security/advisories/new) + on this repository. This is the monitored intake for Coven. +- **Organization-wide findings** (protocol behavior, cross-repository issues, + other OpenCoven repositories) belong in the + [organization-level security policy](https://github.com/OpenCoven/.github/blob/main/SECURITY.md). +- If you cannot use Security Advisories, mark a related tracking issue private + by contacting a maintainer through an organization-owned channel — please do + not depend on any individual's personal account as the reporting path, and + never post exploit details in public issues. + +Coven deliberately publishes **no acknowledgment or remediation deadline**. +Maintainers triage advisories through normal repository maintenance. Adding +response-time commitments requires an accountable process that can meet and +measure them; until such a process exists, this policy does not promise one. +Researchers who responsibly disclose may request credit in a release note, with +their permission. + +**Third-party dependencies and providers.** Findings that live purely inside a +third-party dependency or a model provider's API are best reported upstream to +that maintainer. Report them here as well — via a Security Advisory — when they +materially compromise Coven's supported behavior: bundled or pinned versions, +Coven's integration defaults, credential-handling boundaries, or anything that +turns a dependency flaw into a Coven compromise. + +## 5. Design goals vs guarantees + +The retired repository policy listed broad isolation properties beside enforced +behavior. They are **design goals of the OpenCoven protocol**, not enforced +Coven properties today, and they now live where they belong: + +- **Session isolation** across users and agents, **memory ownership**, and + **familiar identity integrity** are protocol-level goals described in the + [trust layer contract](specs/coven-trust-layer/PRODUCT.md) and related + OpenCoven protocol documents. +- **Agent-to-agent boundary policy and delegation** are active work in + #803 (input-guardrail parity across handoffs) and #804 (invocation and + delegation contracts). Coven's current local Runner does not implement A2A + isolation; do not rely on it as if it did. +- **Execution boundaries** are enforced only to the extent of the properties in + [Enforced properties today](#2-enforced-properties-today). + +A property becomes a Coven guarantee when an executable acceptance or control +family tests it on a shipped release — the table in section 2 is that list. +Violating an enforced property is a security report. A path that would defeat a +design goal (for example, cross-user or cross-agent access) is also worth +reporting, but it should be described as a protocol-boundary finding, not as a +broken Coven guarantee. + +## Policy maintenance + +- This file is the single normative security policy for this repository; the + organization-level default policy is not additive here. +- Update it in the same change as any security or secret-handling rule, per + [documentation maintenance](docs/DOCS-MAINTENANCE.md). +- Internal links are relative repository links so they resolve in both the + repository and deployed-doc contexts; docs link and freshness validation is + tracked under #778. + +*Last updated: 2026-08-30* diff --git a/crates/coven-agents/src/guardrail.rs b/crates/coven-agents/src/guardrail.rs index 7ef83418..e393cf1f 100644 --- a/crates/coven-agents/src/guardrail.rs +++ b/crates/coven-agents/src/guardrail.rs @@ -17,9 +17,15 @@ impl GuardrailVerdict { } #[async_trait] -/// Checks the original user input before the starting agent runs. +/// Checks the bounded ingress of an agent before its first model turn. /// -/// Input guardrails attached only to handoff targets do not run in this MVP. +/// The runner evaluates the starting agent's input guardrails against the +/// original user input before the run begins, and a handoff target's input +/// guardrails against the same original user input before the target's first +/// model turn, so entering an agent through a handoff cannot grant access that +/// direct entry would reject. Input guardrails never inspect a serialized +/// transcript; the structured task/context manifest for delegated invocations +/// is a separate contract (see OpenCoven/coven#804). pub trait InputGuardrail: Send + Sync where C: Sync, diff --git a/crates/coven-agents/src/runner.rs b/crates/coven-agents/src/runner.rs index ed839af0..87360729 100644 --- a/crates/coven-agents/src/runner.rs +++ b/crates/coven-agents/src/runner.rs @@ -128,6 +128,60 @@ where error } + /// Checks an agent's input guardrails against the run's original user + /// input. + /// + /// This is the single ingress path shared by direct starts and handoffs. + /// The evaluated input is always the original user input that started the + /// run — the same bounded string a direct start would check — so entering + /// an agent through a handoff cannot grant access that direct entry would + /// reject. A `GuardrailChecked` event is emitted per guardrail with the + /// owning agent's identity, and a policy rejection or guardrail + /// implementation error fails the run before the agent's next model turn + /// or tool execution. + async fn check_input_guardrails( + &self, + agent: &Agent, + input: &str, + context: &C, + ) -> Result<(), RunError> { + for guardrail in &agent.input_guardrails { + let verdict = guardrail.check(input, context).await.map_err(|source| { + self.fail( + &agent.id, + RunFailureKind::InputGuardrail, + RunError::GuardrailFailed { + agent: agent.id.clone(), + guardrail: guardrail.name().to_owned(), + stage: GuardrailStage::Input, + source, + }, + ) + })?; + let allowed = verdict == GuardrailVerdict::Allow; + self.observer.on_event(&RunEvent::GuardrailChecked { + agent: agent.id.clone(), + guardrail: guardrail.name().to_owned(), + stage: GuardrailStage::Input, + allowed, + }); + if let GuardrailVerdict::Reject { reason } = verdict { + return Err(self.fail( + &agent.id, + RunFailureKind::InputGuardrail, + RunError::GuardrailRejected { + agent: agent.id.clone(), + guardrail: guardrail.name().to_owned(), + stage: GuardrailStage::Input, + reason, + }, + )); + } + } + + Ok(()) + } + /// Runs `starting_agent` to a final output. /// /// Every run emits exactly one `RunStarted` event followed by exactly one @@ -139,6 +193,12 @@ where /// user message, assistant message, and tool calls that preceded it, so /// those items are handed back rather than dropped. The runner never /// appends a failed run's items to the session store. + /// + /// Input guardrails are enforced at every agent boundary: the starting + /// agent's before its first model turn, and each handoff target's against + /// the same original user input before that target's first model turn, so + /// a handoff cannot reach an agent that would have rejected the input as + /// the starting agent. pub async fn run( &self, starting_agent: impl Into, @@ -188,39 +248,8 @@ where ) })?; - for guardrail in ¤t.input_guardrails { - let verdict = guardrail.check(&input, context).await.map_err(|source| { - self.fail( - ¤t.id, - RunFailureKind::InputGuardrail, - RunError::GuardrailFailed { - agent: current.id.clone(), - guardrail: guardrail.name().to_owned(), - stage: GuardrailStage::Input, - source, - }, - ) - })?; - let allowed = verdict == GuardrailVerdict::Allow; - self.observer.on_event(&RunEvent::GuardrailChecked { - agent: current.id.clone(), - guardrail: guardrail.name().to_owned(), - stage: GuardrailStage::Input, - allowed, - }); - if let GuardrailVerdict::Reject { reason } = verdict { - return Err(self.fail( - ¤t.id, - RunFailureKind::InputGuardrail, - RunError::GuardrailRejected { - agent: current.id.clone(), - guardrail: guardrail.name().to_owned(), - stage: GuardrailStage::Input, - reason, - }, - )); - } - } + self.check_input_guardrails(¤t, &input, context) + .await?; let mut model_items = match (&options.session_id, &self.session) { (Some(session_id), Some(session)) => { @@ -460,6 +489,12 @@ where name: handoff.name.clone(), }); current = target; + // Ingress parity: the handoff target enforces the same input + // policy it would enforce as the starting agent, checked + // against the original user input, before its first model turn + // or tool execution. + self.check_input_guardrails(¤t, &input, context) + .await?; continue; } diff --git a/crates/coven-agents/tests/runner.rs b/crates/coven-agents/tests/runner.rs index 1309675d..214eea6c 100644 --- a/crates/coven-agents/tests/runner.rs +++ b/crates/coven-agents/tests/runner.rs @@ -160,6 +160,42 @@ impl OutputGuardrail<()> for RejectOutput { } } +#[derive(Default)] +struct RecordingInputGuardrail { + seen: Mutex>, +} + +impl RecordingInputGuardrail { + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } +} + +#[async_trait] +impl InputGuardrail<()> for RecordingInputGuardrail { + fn name(&self) -> &str { + "record-input" + } + + async fn check(&self, input: &str, _context: &()) -> Result { + self.seen.lock().unwrap().push(input.to_owned()); + Ok(GuardrailVerdict::Allow) + } +} + +struct FailingInputGuardrail; + +#[async_trait] +impl InputGuardrail<()> for FailingInputGuardrail { + fn name(&self) -> &str { + "failing-input" + } + + async fn check(&self, _input: &str, _context: &()) -> Result { + Err(Box::new(io::Error::other("input guardrail exploded")) as BoxError) + } +} + #[derive(Default)] struct RecordingObserver { events: Mutex>, @@ -982,3 +1018,402 @@ async fn unique_tool_call_ids_preserve_tool_execution() { 2 ); } + +#[tokio::test] +async fn handoff_target_enforces_the_same_input_policy_as_direct_entry() { + let direct_model = Arc::new(QueueModel::new([ModelResponse::final_output( + "should not run", + )])); + let direct = Agent::new("specialist", "Specialist", "Be safe.", direct_model.clone()) + .with_input_guardrail(Arc::new(RejectInput)); + let direct_runner = Runner::new([direct]).unwrap(); + + let direct_failure = direct_runner + .run("specialist", "blocked input", &(), RunOptions::default()) + .await + .unwrap_err(); + + assert!(matches!( + direct_failure.error, + RunError::GuardrailRejected { + ref agent, + stage: GuardrailStage::Input, + ref reason, + .. + } if agent.as_str() == "specialist" && reason == "blocked by policy" + )); + assert_eq!(direct_model.calls.load(Ordering::SeqCst), 0); + + let triage_model = Arc::new(QueueModel::new([ModelResponse::actions(vec![ + ModelAction::Handoff(HandoffCall::new("to-specialist")), + ])])); + let specialist_model = Arc::new(QueueModel::new([ModelResponse::final_output( + "must not run either", + )])); + let target_tool_calls = Arc::new(AtomicUsize::new(0)); + let triage = Agent::new("triage", "Triage", "Route the request.", triage_model).with_handoff( + Handoff::new("to-specialist", "Use for specialist work", "specialist"), + ); + let specialist = Agent::new( + "specialist", + "Specialist", + "Handle specialist work.", + specialist_model.clone(), + ) + .with_input_guardrail(Arc::new(RejectInput)) + .with_tool(Arc::new(CountingCallTool { + calls: target_tool_calls.clone(), + })); + let observer = Arc::new(RecordingObserver::default()); + let runner = Runner::new([triage, specialist]) + .unwrap() + .with_observer(observer.clone()); + + let failure = runner + .run("triage", "blocked input", &(), RunOptions::default()) + .await + .unwrap_err(); + + assert!(matches!( + failure.error, + RunError::GuardrailRejected { + ref agent, + stage: GuardrailStage::Input, + ref reason, + .. + } if agent.as_str() == "specialist" && reason == "blocked by policy" + )); + assert_eq!( + specialist_model.calls.load(Ordering::SeqCst), + 0, + "a handoff target that rejects the input must not receive a model call" + ); + assert_eq!( + target_tool_calls.load(Ordering::SeqCst), + 0, + "a handoff target that rejects the input must not execute tools" + ); + assert_eq!(failure.turns, 1, "only the source agent's turn ran"); + assert_eq!(failure.handoffs, 1); + assert!(matches!( + failure.new_items.as_slice(), + [RunItem::UserMessage { .. }, RunItem::Handoff { to, .. }] if to.as_str() == "specialist" + )); + let events = observer.events(); + assert_paired_lifecycle(&events); + assert!(events.iter().any(|event| matches!( + event, + RunEvent::GuardrailChecked { + agent, + stage: GuardrailStage::Input, + allowed: false, + .. + } if agent.as_str() == "specialist" + ))); + assert!(events.iter().any(|event| matches!( + event, + RunEvent::RunFailed { + kind: RunFailureKind::InputGuardrail, + .. + } + ))); +} + +#[tokio::test] +async fn handoff_target_input_guardrail_runs_before_the_target_model_turn() { + let triage_model = Arc::new(QueueModel::new([ModelResponse::actions(vec![ + ModelAction::Handoff(HandoffCall::new("to-specialist")), + ])])); + let specialist_model = Arc::new(QueueModel::new([ModelResponse::final_output( + "Handled by the specialist.", + )])); + let specialist_guardrail = Arc::new(RecordingInputGuardrail::default()); + let triage = Agent::new("triage", "Triage", "Route the request.", triage_model).with_handoff( + Handoff::new("to-specialist", "Use for specialist work", "specialist"), + ); + let specialist = Agent::new( + "specialist", + "Specialist", + "Handle specialist work.", + specialist_model, + ) + .with_input_guardrail(specialist_guardrail.clone()); + let observer = Arc::new(RecordingObserver::default()); + let runner = Runner::new([triage, specialist]) + .unwrap() + .with_observer(observer.clone()); + + let result = runner + .run("triage", "Please route this.", &(), RunOptions::default()) + .await + .unwrap(); + + assert_eq!(result.final_agent.as_str(), "specialist"); + assert_eq!( + specialist_guardrail.seen(), + ["Please route this."], + "the target's ingress policy evaluates the original user input, the same value a direct start would check" + ); + + let events = observer.events(); + let handoff_position = events + .iter() + .position( + |event| matches!(event, RunEvent::Handoff { to, .. } if to.as_str() == "specialist"), + ) + .unwrap(); + let checked_position = events + .iter() + .position(|event| { + matches!(event, RunEvent::GuardrailChecked { agent, .. } if agent.as_str() == "specialist") + }) + .unwrap(); + let target_model_position = events + .iter() + .position(|event| { + matches!(event, RunEvent::ModelRequested { agent, .. } if agent.as_str() == "specialist") + }) + .unwrap(); + assert!( + handoff_position < checked_position && checked_position < target_model_position, + "the target's ingress check must land between the handoff and the target's first model turn, got {events:?}" + ); + assert!(matches!( + &events[checked_position], + RunEvent::GuardrailChecked { + stage: GuardrailStage::Input, + allowed: true, + .. + } + )); +} + +#[tokio::test] +async fn multi_hop_handoff_enforces_input_policy_at_every_boundary() { + let a_model = Arc::new(QueueModel::new([ModelResponse::actions(vec![ + ModelAction::Handoff(HandoffCall::new("to-b")), + ])])); + let b_model = Arc::new(QueueModel::new([ModelResponse::actions(vec![ + ModelAction::Handoff(HandoffCall::new("to-c")), + ])])); + let c_model = Arc::new(QueueModel::new([ModelResponse::final_output("Done by c.")])); + let b_guardrail = Arc::new(RecordingInputGuardrail::default()); + let c_guardrail = Arc::new(RecordingInputGuardrail::default()); + let a = Agent::new("a", "A", "Route.", a_model).with_handoff(Handoff::new( + "to-b", + "Route to b", + "b", + )); + let b = Agent::new("b", "B", "Route.", b_model) + .with_handoff(Handoff::new("to-c", "Route to c", "c")) + .with_input_guardrail(b_guardrail.clone()); + let c = Agent::new("c", "C", "Answer.", c_model).with_input_guardrail(c_guardrail.clone()); + let observer = Arc::new(RecordingObserver::default()); + let runner = Runner::new([a, b, c]) + .unwrap() + .with_observer(observer.clone()); + + let result = runner + .run("a", "Cross the coven.", &(), RunOptions::default()) + .await + .unwrap(); + + assert_eq!(result.final_agent.as_str(), "c"); + assert_eq!(result.handoffs, 2); + assert_eq!(result.turns, 3); + assert_eq!(b_guardrail.seen(), ["Cross the coven."]); + assert_eq!(c_guardrail.seen(), ["Cross the coven."]); + + let events = observer.events(); + let b_check = events + .iter() + .position(|event| { + matches!(event, RunEvent::GuardrailChecked { agent, .. } if agent.as_str() == "b") + }) + .unwrap(); + let c_check = events + .iter() + .position(|event| { + matches!(event, RunEvent::GuardrailChecked { agent, .. } if agent.as_str() == "c") + }) + .unwrap(); + assert!( + b_check < c_check, + "each hop must clear its own ingress policy before the next model turn, got {events:?}" + ); +} + +#[tokio::test] +async fn multi_hop_handoff_target_rejection_prevents_the_target_model_turn() { + let a_model = Arc::new(QueueModel::new([ModelResponse::actions(vec![ + ModelAction::Handoff(HandoffCall::new("to-b")), + ])])); + let b_model = Arc::new(QueueModel::new([ModelResponse::actions(vec![ + ModelAction::Handoff(HandoffCall::new("to-c")), + ])])); + let c_model = Arc::new(QueueModel::new([ModelResponse::final_output( + "must not run", + )])); + let a = Agent::new("a", "A", "Route.", a_model).with_handoff(Handoff::new( + "to-b", + "Route to b", + "b", + )); + let b = Agent::new("b", "B", "Route.", b_model).with_handoff(Handoff::new( + "to-c", + "Route to c", + "c", + )); + let c = Agent::new("c", "C", "Answer.", c_model.clone()) + .with_input_guardrail(Arc::new(RejectInput)); + let observer = Arc::new(RecordingObserver::default()); + let runner = Runner::new([a, b, c]) + .unwrap() + .with_observer(observer.clone()); + + let failure = runner + .run("a", "blocked input", &(), RunOptions::default()) + .await + .unwrap_err(); + + assert!(matches!( + failure.error, + RunError::GuardrailRejected { + ref agent, + stage: GuardrailStage::Input, + .. + } if agent.as_str() == "c" + )); + assert_eq!( + c_model.calls.load(Ordering::SeqCst), + 0, + "the rejecting target must not receive a model call" + ); + assert_eq!(failure.turns, 2, "only the upstream agents' turns ran"); + assert_eq!(failure.handoffs, 2); + assert_paired_lifecycle(&observer.events()); + assert!(observer.events().iter().any(|event| matches!( + event, + RunEvent::RunFailed { + kind: RunFailureKind::InputGuardrail, + .. + } + ))); +} + +#[tokio::test] +async fn handoff_target_guardrail_error_is_distinguishable_from_a_rejection() { + let triage_model = Arc::new(QueueModel::new([ModelResponse::actions(vec![ + ModelAction::Handoff(HandoffCall::new("to-specialist")), + ])])); + let specialist_model = Arc::new(QueueModel::new([ModelResponse::final_output("unused")])); + let triage = Agent::new("triage", "Triage", "Route the request.", triage_model).with_handoff( + Handoff::new("to-specialist", "Use for specialist work", "specialist"), + ); + let specialist = Agent::new( + "specialist", + "Specialist", + "Handle specialist work.", + specialist_model.clone(), + ) + .with_input_guardrail(Arc::new(FailingInputGuardrail)); + let observer = Arc::new(RecordingObserver::default()); + let runner = Runner::new([triage, specialist]) + .unwrap() + .with_observer(observer.clone()); + + let failure = runner + .run("triage", "Any input.", &(), RunOptions::default()) + .await + .unwrap_err(); + + assert!(matches!( + failure.error, + RunError::GuardrailFailed { + ref agent, + ref guardrail, + stage: GuardrailStage::Input, + .. + } if agent.as_str() == "specialist" && guardrail == "failing-input" + )); + assert_eq!( + failure.to_string(), + "input guardrail `failing-input` for agent `specialist` failed", + "an implementation error must not read as a policy rejection" + ); + assert_eq!( + specialist_model.calls.load(Ordering::SeqCst), + 0, + "a guardrail implementation error must still stop the target model turn" + ); + assert_paired_lifecycle(&observer.events()); +} + +#[tokio::test] +async fn failing_input_guardrail_is_distinguishable_from_a_rejection() { + let model = Arc::new(QueueModel::new([ModelResponse::final_output("unused")])); + let agent = Agent::new("safe", "Safe", "Be safe.", model.clone()) + .with_input_guardrail(Arc::new(FailingInputGuardrail)); + let observer = Arc::new(RecordingObserver::default()); + let runner = Runner::new([agent]) + .unwrap() + .with_observer(observer.clone()); + + let failure = runner + .run("safe", "Any input.", &(), RunOptions::default()) + .await + .unwrap_err(); + + assert_eq!( + failure.to_string(), + "input guardrail `failing-input` for agent `safe` failed" + ); + assert_eq!(model.calls.load(Ordering::SeqCst), 0); + assert_paired_lifecycle(&observer.events()); + assert!(observer.events().iter().any(|event| matches!( + event, + RunEvent::RunFailed { + kind: RunFailureKind::InputGuardrail, + .. + } + ))); +} + +#[tokio::test] +async fn handoff_cannot_be_combined_with_tool_calls() { + let model = Arc::new(QueueModel::new([ModelResponse { + assistant_message: Some("Routing and calculating.".to_owned()), + actions: vec![ + ModelAction::Handoff(HandoffCall::new("to-worker")), + ModelAction::ToolCall(ToolCall::new( + "call-1", + "add", + json!({ "left": 1, "right": 2 }), + )), + ], + }])); + let calls = Arc::new(AtomicUsize::new(0)); + let worker_model = Arc::new(QueueModel::new([ModelResponse::final_output("unused")])); + let triage = Agent::new("triage", "Triage", "Route.", model).with_handoff(Handoff::new( + "to-worker", + "Route to worker", + "worker", + )); + let worker = Agent::new("worker", "Worker", "Use tools.", worker_model).with_tool(Arc::new( + CountingCallTool { + calls: calls.clone(), + }, + )); + let runner = Runner::new([triage, worker]).unwrap(); + + let failure = runner + .run("triage", "Route and add.", &(), RunOptions::default()) + .await + .unwrap_err(); + + assert!(matches!( + failure.error, + RunError::InvalidModelResponse { ref reason, .. } if reason == "a handoff cannot be combined with other actions" + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} diff --git a/crates/coven-cli/src/api.rs b/crates/coven-cli/src/api.rs index cb466a99..d45c32c6 100644 --- a/crates/coven-cli/src/api.rs +++ b/crates/coven-cli/src/api.rs @@ -1,5 +1,4 @@ use std::{ - borrow::Cow, collections::{BTreeMap, HashSet}, fs, io::Write, @@ -17,6 +16,7 @@ use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{ + api_routes::{normalize_api_route, split_path_query, ApiRoute}, control_plane, daemon::DaemonStatus, encrypted_artifacts::SensitiveArtifactStore, @@ -28,10 +28,8 @@ use crate::{ const MAX_EVENTS_LIMIT: i64 = 1_000; const EVENT_CANDIDATE_BATCH_LIMIT: usize = 16; const MAX_EVENT_CANDIDATE_BYTES: usize = coven_client::MAX_RESPONSE_BODY_BYTES; -pub const COVEN_API_ROUTE_VERSION: &str = "v1"; pub const COVEN_API_NAMED_VERSION: &str = "coven.daemon.v1"; pub const COVEN_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub const SUPPORTED_API_ROUTE_VERSIONS: [&str; 1] = [COVEN_API_ROUTE_VERSION]; fn proposal_decision_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); @@ -528,7 +526,7 @@ pub(crate) fn handle_request_with_runtime_and_authority( "Unsupported API version.", Some(json!({ "apiVersion": version, - "supportedApiVersions": SUPPORTED_API_ROUTE_VERSIONS, + "supportedApiVersions": crate::api_routes::SUPPORTED_API_ROUTE_VERSIONS, })), ); } @@ -540,8 +538,8 @@ pub(crate) fn handle_request_with_runtime_and_authority( ("GET", "/api-version") => json_response( 200, &json!({ - "apiVersion": COVEN_API_ROUTE_VERSION, - "supportedApiVersions": SUPPORTED_API_ROUTE_VERSIONS, + "apiVersion": crate::api_routes::COVEN_API_ROUTE_VERSION, + "supportedApiVersions": crate::api_routes::SUPPORTED_API_ROUTE_VERSIONS, }), ), ("GET", "/health") => json_response( @@ -1000,28 +998,6 @@ pub(crate) fn handle_request_with_runtime_and_authority( } } -enum ApiRoute<'a> { - Route(Cow<'a, str>), - Unsupported(String), - Malformed, -} - -fn normalize_api_route(route: &str) -> ApiRoute<'_> { - let Some(rest) = route.strip_prefix("/api/") else { - return ApiRoute::Route(Cow::Borrowed(route)); - }; - let Some((version, suffix)) = rest.split_once('/') else { - return ApiRoute::Malformed; - }; - if version != COVEN_API_ROUTE_VERSION { - return ApiRoute::Unsupported(version.to_string()); - } - if suffix.is_empty() { - return ApiRoute::Malformed; - } - ApiRoute::Route(Cow::Owned(format!("/{suffix}"))) -} - pub(crate) fn store_path(coven_home: &Path) -> std::path::PathBuf { coven_home.join("coven.sqlite3") } @@ -8740,13 +8716,6 @@ pub(crate) fn parse_body(body: Option<&str>) -> Result { } } -fn split_path_query(path: &str) -> (&str, Option<&str>) { - match path.split_once('?') { - Some((route, query)) => (route, Some(query)), - None => (path, None), - } -} - pub(crate) fn query_param<'a>(query: &'a str, key: &str) -> Option<&'a str> { query.split('&').find_map(|part| { let (candidate, value) = part.split_once('=')?; @@ -8827,6 +8796,7 @@ pub(crate) fn json_response(status: u16, body: &T) -> Result anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + let response = handle_request("GET", "/api/v2/health", temp_dir.path(), None)?; + let body: serde_json::Value = serde_json::from_str(&response.body)?; + + assert_eq!(response.status, 404); + assert_eq!(body["error"]["code"], "invalid_request"); + assert_eq!(body["error"]["message"], "Unsupported API version."); + assert_eq!(body["error"]["details"]["apiVersion"], "v2"); + assert_eq!( + body["error"]["details"]["supportedApiVersions"], + json!(["v1"]) + ); + Ok(()) + } + + #[test] + fn rejects_malformed_api_route_prefixes() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + + // The gate (and the router's fallback for non-API passthroughs) must + // answer every unrouteable shape with the same 404 envelope: callers + // cannot distinguish where the path was refused. + for path in ["/api/v1/", "/api/v1", "/api/", "/api"] { + let response = handle_request("GET", path, temp_dir.path(), None)?; + let body: serde_json::Value = serde_json::from_str(&response.body)?; + + assert_eq!(response.status, 404, "path {path:?}"); + assert_eq!(body["error"]["code"], "not_found", "path {path:?}"); + assert_eq!( + body["error"]["message"], "Route not found.", + "path {path:?}" + ); + } + Ok(()) + } + #[test] fn routes_control_capabilities_discovery_to_json() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; diff --git a/crates/coven-cli/src/api_routes.rs b/crates/coven-cli/src/api_routes.rs new file mode 100644 index 00000000..eefb3d8d --- /dev/null +++ b/crates/coven-cli/src/api_routes.rs @@ -0,0 +1,158 @@ +//! Route/version authority gate for the Coven daemon HTTP API. +//! +//! Every API request enters through [`crate::api::handle_request`] (or its +//! `_with_body` / `_with_runtime` / `_with_runtime_and_authority` variants), +//! and the first thing that entry point does is split the raw path with +//! [`split_path_query`] and classify the route with [`normalize_api_route`] +//! here. This module is the single place that decides whether a request path +//! carries a supported API version, so handlers below the gate never re-parse +//! the `/api/` prefix and cannot be reached under an unsupported +//! version. +//! +//! Contract (pinned by the tests below and by the envelope tests in +//! `crate::api`): +//! +//! - paths without the `/api/` prefix pass through unchanged (borrowed, no +//! allocation); +//! - `/api//` is rewritten to `/`; +//! - any other `/api/...` shape is rejected before dispatch: an unsupported +//! version answers `404 invalid_request` with an `apiVersion` and +//! `supportedApiVersions` payload, and every other malformed shape answers +//! `404 not_found`; +//! - the query string is split off the raw path before classification and is +//! never part of the classified route. +//! +//! New routes belong in the `crate::api` dispatch behind this gate — never in +//! a helper that skips it. Route/version policy changes belong here; +//! validation, persistence, and response mapping stay in their own seams (see +//! `docs/authority-module-inventory.md`). + +use std::borrow::Cow; + +/// The only API route version this authority currently accepts. +pub const COVEN_API_ROUTE_VERSION: &str = "v1"; + +/// Route versions advertised to clients that send an unsupported version. +pub const SUPPORTED_API_ROUTE_VERSIONS: [&str; 1] = [COVEN_API_ROUTE_VERSION]; + +/// Classification of a raw request path after its query string was split off. +#[derive(Debug)] +pub(crate) enum ApiRoute<'a> { + /// A routable path: either a non-`/api/` path passed through unchanged, or + /// an `/api//` path with the version prefix + /// stripped. Never retains a version prefix or a `?query` suffix. + Route(Cow<'a, str>), + /// `/api//...` with a version this authority does not support. + Unsupported(String), + /// An `/api/...` path that does not carry a routable suffix. + Malformed, +} + +/// Classify a raw request path (already split from its query string). +pub(crate) fn normalize_api_route(route: &str) -> ApiRoute<'_> { + let Some(rest) = route.strip_prefix("/api/") else { + return ApiRoute::Route(Cow::Borrowed(route)); + }; + let Some((version, suffix)) = rest.split_once('/') else { + return ApiRoute::Malformed; + }; + if version != COVEN_API_ROUTE_VERSION { + return ApiRoute::Unsupported(version.to_string()); + } + if suffix.is_empty() { + return ApiRoute::Malformed; + } + ApiRoute::Route(Cow::Owned(format!("/{suffix}"))) +} + +/// Split the raw request path into its route part and optional query string. +pub(crate) fn split_path_query(path: &str) -> (&str, Option<&str>) { + match path.split_once('?') { + Some((route, query)) => (route, Some(query)), + None => (path, None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn routes_supported_version_paths_to_stripped_route() { + match normalize_api_route("/api/v1/health") { + ApiRoute::Route(route) => assert_eq!(route, "/health"), + other => panic!("expected a route, got {other:?}"), + } + match normalize_api_route("/api/v1/sessions/session-1/input") { + ApiRoute::Route(route) => assert_eq!(route, "/sessions/session-1/input"), + other => panic!("expected a route, got {other:?}"), + } + } + + #[test] + fn passes_non_api_paths_through_without_allocation() { + for path in ["/health", "", "/", "/api", "api/v1/health"] { + match normalize_api_route(path) { + ApiRoute::Route(Cow::Borrowed(passed)) => assert_eq!(passed, path), + other => panic!("expected a borrowed passthrough for {path:?}, got {other:?}"), + } + } + } + + #[test] + fn rejects_unsupported_api_versions() { + for (path, version) in [ + ("/api/v2/health", "v2"), + ("/api/V1/health", "V1"), + ("/api//health", ""), + ] { + match normalize_api_route(path) { + ApiRoute::Unsupported(unsupported) => assert_eq!(unsupported, version), + other => panic!("expected an unsupported version for {path:?}, got {other:?}"), + } + } + } + + #[test] + fn rejects_malformed_api_prefixes() { + for path in ["/api/", "/api/v1", "/api/v1/"] { + assert!( + matches!(normalize_api_route(path), ApiRoute::Malformed), + "expected a malformed classification for {path:?}" + ); + } + } + + #[test] + fn stripped_routes_never_retain_version_prefix_or_query() { + for path in [ + "/api/v1/health", + "/api/v1/health?refresh=1", + "/api/v1/sessions?limit=2&cursor=abc", + "/api/v1/events?sessionId=s-1", + ] { + let (route, _query) = split_path_query(path); + match normalize_api_route(route) { + ApiRoute::Route(stripped) => { + assert!(!stripped.starts_with("/api/"), "{stripped:?}"); + assert!(!stripped.contains('?'), "{stripped:?}"); + } + other => panic!("expected a route for {path:?}, got {other:?}"), + } + } + } + + #[test] + fn splits_path_and_query() { + assert_eq!(split_path_query("/health"), ("/health", None)); + assert_eq!( + split_path_query("/events?sessionId=s-1"), + ("/events", Some("sessionId=s-1")) + ); + assert_eq!( + split_path_query("/sessions?limit=2&cursor=a=b"), + ("/sessions", Some("limit=2&cursor=a=b")) + ); + assert_eq!(split_path_query("/events?"), ("/events", Some(""))); + } +} diff --git a/crates/coven-cli/src/main.rs b/crates/coven-cli/src/main.rs index 68fe3424..f77636f9 100644 --- a/crates/coven-cli/src/main.rs +++ b/crates/coven-cli/src/main.rs @@ -20,6 +20,7 @@ use uuid::Uuid; mod afs; mod afs_mount; mod api; +mod api_routes; mod capabilities; mod cockpit_sources; mod config_paths; diff --git a/crates/coven-relay/src/ws.rs b/crates/coven-relay/src/ws.rs index 54724648..227b4899 100644 --- a/crates/coven-relay/src/ws.rs +++ b/crates/coven-relay/src/ws.rs @@ -129,7 +129,7 @@ impl RelayState { return; }; let slot = room.slot_mut(role); - if !slot.as_ref().is_some_and(|peer| peer.id == peer_id) { + if slot.as_ref().is_none_or(|peer| peer.id != peer_id) { return; } *slot = None; diff --git a/docs/DOCS-MAINTENANCE.md b/docs/DOCS-MAINTENANCE.md index e9ea0209..f14982c8 100644 --- a/docs/DOCS-MAINTENANCE.md +++ b/docs/DOCS-MAINTENANCE.md @@ -35,6 +35,49 @@ When moving a topic: 4. Keep normative details here only when the public page links back to the source contract. +## Public-doc directory boundary + +The repository's public-doc directories may contain only two kinds of pages: + +- **Canonical pointers** — a stable repository entry point whose body links to + the canonical `docs.opencoven.ai` route. Use this shape: + + ```md + --- + title: "" + description: "Pointer to the canonical guidance." + --- + + Canonical guidance: **https://docs.opencoven.ai/docs/** + + + ``` + +- **Source-adjacent exceptions** — a page that must evolve with the code + (contracts, maintainer source maps, verification procedures). Every retained + page states its source-adjacent ownership reason, either in the page itself + or in the ownership table in [`README.md`](../README.md) and + [`docs/index.md`](index.md). + +Public-doc directories today: `docs/install/`, `docs/platforms/`, +`docs/start/`, `docs/help/`, `docs/harnesses/`, `docs/models/`, +`docs/memory/`, `docs/guides/`, `docs/reference/`, and the public operation +pages of `docs/daemon/`. Source-adjacent trees (`docs/design/`, +`docs/development/`, `docs/superpowers/`, `docs/architecture/`, +`docs/security/`) and the top-level normative contracts are exempt. + +Do not add a new public page to these directories, and do not restore +duplicated prose. If a canonical target is missing, the local page stays +unchanged until the canonical coverage lands in `coven-docs` — topical +similarity alone is not duplication, and an absent canonical target blocks +removal, never forces a rewrite here. + +Public user guidance pages that remain because their canonical target is still +pending (for example the platform pages retained until +`scripts/onboarding-docs-test.mjs` is migrated to canonical-pointer +expectations) are listed as pending exceptions in the tracking issue, not +silently kept. + ## Public content stance All committed documentation is public. It should describe OpenCoven and Coven diff --git a/docs/architecture/coven-automations-v1.md b/docs/architecture/coven-automations-v1.md new file mode 100644 index 00000000..c668389d --- /dev/null +++ b/docs/architecture/coven-automations-v1.md @@ -0,0 +1,288 @@ +# Coven Automations Protocol v1 (`coven.automations.v1`) + +Status: Proposed implementation contract + +Tracks: OpenCoven/coven#855 (this specification), OpenCoven/coven#854 (parent program), OpenCoven/coven#816 (landed foundation) + +Machine-readable artifacts: [`spec/coven-automations/v1/`](../../spec/coven-automations/v1/) — JSON Schemas, state machines, capability negotiation, compatibility matrix, golden vectors, and a pinned TypeScript projection. + +## Normative language + +The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY are to be interpreted as normative requirements. + +## Purpose + +Turn the internal automation structs and control actions into a stable, independently testable protocol so Cave, the SDK, Psyche adapters, runtimes, and future implementations can consume automations without importing Coven internals or reproducing lifecycle semantics by hand. The protocol is one canonical truth for definitions, occurrences, runs, attempts, authority bindings, receipts, commands, events, and failures. + +This document specifies the contract. It does not change schedule authority, familiar identity, or authority semantics: those stay in their canonical layers (see [Implementation boundaries](#implementation-boundaries)). + +## Current gap, with code paths + +The #816 foundation is a valid v1 Rust implementation, but the public contract is still inferred from implementation. Each gap below cites the code as of this writing: + +1. **Definitions are schedule + prompt records.** `RoutineDefinition` (`crates/coven-cli/src/automations/definition.rs`) carries `schemaVersion`, `id`, `name`, `status` (`ACTIVE | PAUSED`), `rrule`, `timeoutMinutes`, `runtime`, `familiarId`, `prompt`, and little else. There is no revision counter, no integrity digest, no trigger/action unions, no lifecycle beyond two states, no provenance, and no retention policy. +2. **JSON payloads are assembled inside the control action router.** Every wire shape is hand-built in `control_plane.rs` (`automation_list_payload`, `automation_create_payload`, `automation_runs_payload`, ... at `crates/coven-cli/src/control_plane.rs`), so the router is the only specification of the payloads. +3. **Domain failures hide inside accepted responses.** `automation_event` (`control_plane.rs`) always returns `ok: true, accepted: true, status: completed`; when the store call fails, the error is embedded as a `{"error": ...}` payload (for example `automation_tick_payload`, `automation_run_payload`, `automation_update_payload`). A client that trusts `accepted` cannot see the failure. +4. **No revision/adoption model.** `update_definition` (`crates/coven-cli/src/automations/store.rs`) mutates the row in place; nothing records which revision a caller expected, and `intentId` on control actions (`control_plane.rs`) is echoed, never adopted, stored, or replay-checked. +5. **No versioned event envelope or replay reducer.** `ControlEvent` (`control_plane.rs`) has `kind/action/origin/intentId/payload` but no schema version, no event id, no per-stream sequence, and no timestamps; there is no changefeed at all — Cave polls list/get endpoints. +6. **Lifecycle semantics are stringly typed and partial.** Occurrence states are free strings (`'planned'/'claimed'/'running'/'succeeded'/'failed'` in `crates/coven-cli/src/automations/occurrences.rs`), terminal states are exactly `[succeeded, failed]` (`OCCURRENCE_TERMINAL_STATES`), and lease recovery maps straight to `failed` with reason `lease expired` (`recover_expired_leases`). There are no eligible/dispatching/recovering/cancelled/timed_out/superseded states, no run state machine beyond `status` strings, and no attempt object at all — only an `attempt` counter incremented by claim (`claim_due_occurrence`). +7. **No receipts, no digests, no integrity anywhere** in the automations module; run outcomes are ledger rows (`automation_runs.log_json`, `exit_code`) with no tamper-evident summary. +8. **No capability negotiation.** `capabilities()` (`control_plane.rs`) lists action ids, but nothing lets a client ask which trigger/action/policy variants an implementation executes, and nothing forces a definition with an unsupported variant to fail explicitly. +9. **Adoption-key gaps.** Run and occurrence ids are wall-clock derived (`fresh_id`, `crates/coven-cli/src/automations/runner.rs` — `format!("{prefix}-{millis}")`), so a retried `coven.automations.run` command can create a second occurrence/run rather than replaying the first outcome. + +## Contract profile and versioning + +- The wire contract profile is the string `coven.automations.v1`, carried as `schemaVersion` on every object (`spec/coven-automations/v1/protocol-version.json`). +- Contract version is separate from implementation/release version: envelopes carry the producer's `implementationVersion` alongside the contract profile, and clients MUST NOT infer semantics from release versions. +- Unknown profiles fail closed with `SCHEMA_VERSION_UNSUPPORTED` (`coven.automations.v0` and future `coven.automations.v2` are both refusals — golden vectors pin both). +- Additive evolution rules and per-field change classes are machine-readable in `compatibility-matrix.json`; see [Compatibility and evolution](#compatibility-and-evolution). + +## Data model + +All objects are JSON per draft 2020-12 schemas under `spec/coven-automations/v1/`, with `additionalProperties: false`: unknown fields fail closed, and optional, non-semantic data travels only in the explicit `extensions` bag (keys `x-*` or reverse-DNS; preserved on round-trip, never interpreted until promoted by a new profile). + +### `AutomationDefinition` + +Specified by `automation-definition.schema.json`. Field groups (all required unless noted): + +- `automationId` — stable identity, charset `[A-Za-z0-9._-]`, 1..=96 chars, matching the #816 validator in `definition.rs` (`AUTOMATION_ID_MAX_CHARS`). +- `revision` — monotonic integer, starts at 1, incremented by exactly one per accepted mutating command. +- `integrity` — SHA-256 over the RFC 8785 (JCS) canonical serialization of the definition body with the `integrity` member removed (`common.schema.json#/$defs/digest`). The digest pins what a revision means; receipts and occurrences pin it so history stays verifiable even if the definition store is later revised or lost. +- `schemaVersion` — the constant `coven.automations.v1`. The #816 numeric `schemaVersion: 1` is the pre-contract encoding (see [Migration](#migration-from-816)). +- `lifecycleState` — `draft | paused | active | disabled | invalid` (tombstoning is a deletion marker, not a state; see below). +- `display` — `name` (required, 1..=160), optional `description`, `tags`. +- `trigger` — exactly one versioned union. v1 ratifies `schedule` only (scoped RRULE per `crates/coven-cli/src/automations/rrule.rs`: `FREQ=DAILY|WEEKLY`, optional `BYHOUR`, optional `BYDAY` for weekly; anything else is refused at validation). The union admits future variants as new branches in future profiles without redefining v1 fields, and consumers reject unknown variants via capability negotiation. +- `conditions` — zero or more; v1 defines zero variants (the slot is a boolean-false schema), so any value fails validation until a future profile adds branches. +- `action` — exactly one versioned union. v1 ratifies `familiarInvocation` (non-empty `prompt`, optional `cwd`), mirroring the #816 prompt requirement and the runner's no-cwd failure rule. +- `binding` — `familiarBindingPolicy: "exact"` plus `familiarId` and the authority/approval policy reference. Exact binding is the only v1 policy: the familiar recorded at activation is the familiar every run binds; rebinding requires a new revision. +- `runtimeRequirements` — `runtimeId` + capability keys + optional model (required for active/paused/disabled definitions; the #816 `runtime` field maps to `runtimeId`, default `coven-code`). +- `policies` — `timeout.perRunMinutes` (1..=44640, as `definition.rs`), `retry` (max attempts 1..=10, backoff policy, retryable failure classes), `concurrency.overlap: forbid` (the #816 `RoutineOverlap::Forbid`), `misfire.disposition: latest` (the #816 `RoutineMisfire::Latest`), `delivery` (optional atomic `outputTarget` — commit only after a completed, verified run, per `definition.rs`), and `retention` classes for occurrence history, run logs, and receipts. +- `provenance` — creator principal, creation/update timestamps, optional `importedFrom` marker (set to `codex.automation.toml` by the #816 importer in `import_legacy.rs`). +- `activation` — optional `effectiveFrom`/`effectiveUntil` window; outside the window the definition behaves as paused without a lifecycle change. + +### `AutomationOccurrence` + +Specified by `automation-occurrence.schema.json`. + +- `occurrenceId`, `automationId`, `automationRevision` (exact revision pinned forever). +- `triggerIdentity` (`schedule.slot` with `rruleRef`, or `manual.request` with requester) and the canonical `occurrenceKey`: `automationId@scheduledFor` for slots — this is the wire projection of the idempotent planning fence `UNIQUE(automation_id, scheduled_for)` (`crates/coven-cli/src/automations/occurrences.rs`), and `automationId@manual-` for manual runs. +- `scheduledFor` / `observedAt` / `eligibleAt` timestamps. +- `fence` — monotonic `generation` per occurrence plus claimant and lease expiry; the contract-level form of the lease columns and `attempt` counter in `occurrences.rs`. +- `state` + `stateReason` — the occurrence state machine (below). +- `misfireDisposition` — `collapsed_to_latest` records the #816 misfire-latest collapse (`plan_latest_due_occurrence` walks forward from the later of creation time and the latest fenced slot and fences exactly one slot; `occurrences.rs`). +- `claimMetadata` (bounded lease 1..=1440 minutes, per `claim_due_occurrence`). +- `activeRunRef` — present while exactly one accepted run owns the fence generation. +- `cancellation` — request vs acknowledgment vs reconciliation timestamps (cancellation is a request until acknowledged or reconciled). +- `recovery` — evidence class and resolved disposition for the recovering/recovery_required path. +- `eventWindow` — first/last sequence of the occurrence's authoritative stream, so readers can resume without gaps. + +### `AutomationRun` + +Specified by `automation-run.schema.json`. + +- `runId`, occurrence correlation (`occurrenceId`, `automationId`, `automationRevision`). +- `binding` — the exact familiar, the exact principal/authority/approval (references only; authority semantics live in the canonical authority layer), and the exact runtime descriptor/capabilities observed at dispatch. These are frozen at acceptance; retries change attempts, never the run's bindings. +- `state` — `accepted | running | succeeded | failed | cancelled | timed_out | ambiguous`. +- `attemptCount` (monotonic) and `currentAttemptId` while unsettled. +- `terminalDisposition` (required when terminal) with outcome and failure class. +- `delivery` (none/pending/committed/refused/rolled_back; `committed` only after a completed, verified run — the #816 atomic output rule), artifact references, `resultDigest`. +- `receiptRef` — the receipt recording this run's disposition. +- `startedAt` (required) and `finishedAt` (required when terminal — schema-enforced). + +### `AutomationAttempt` + +Specified by `automation-attempt.schema.json`. + +- `attemptId`, `runId`, `occurrenceId`, monotonic `attemptNumber` (never reused within a run). +- `adoptionKey` — workers adopt by key; replays return the same attempt instead of creating a second one. +- `priorDisposition` — required when `attemptNumber > 1`: every retry names the attempt and outcome it retries (`failed | timed_out | ambiguous | cancelled`). +- `dispatchFence` — the occurrence fence generation plus a dispatch generation guarding double dispatch. +- `workerCorrelation` — worker id and the single bound `sessionId`; a second session bind on one attempt is `ILLEGAL_TRANSITION`, never a silent overwrite. +- `retryClassification` — initial / automatic_retry / operator_retry / operator_recovery, with the eligible-classes snapshot. +- `leaseObservations` — heartbeat evidence; expired evidence moves work to recovery and can never be reinterpreted as success. +- `outputCursors` — event/log cursors a resuming worker continues from without re-emitting. +- `state` — `adopted → dispatching → started → observing → succeeded | failed | cancelled | timed_out | ambiguous`. `ambiguous` is terminal for the attempt (see state machines). + +### `AutomationReceipt` + +Specified by `automation-receipt.schema.json`. Immutable and versioned; written once, never revised. + +- Pins `definitionDigest` (the revision's digest), the occurrence fence generation, run, attempt, exact identity, authority/approval, and runtime. +- `exercisedCapabilities` and `sideEffectClass` (`none → local_read → local_write → external_read → external_mutation → irreversible_external_mutation`), which drive whether ambiguous work can be resolved as `failed_deterministic`. +- `outcome` with partial failures and recovery disposition; timestamps; `producer` identity. +- `integrity` — digest over the canonical receipt body plus an `authentication` marker (`none | producer-hmac | cosign`); unauthenticated receipts are integrity-checked but not provenance-proof, and consumers MUST surface the distinction. +- `privacy` — classification and retention. + +## Lifecycle semantics (state machines) + +Machine-readable source of truth: `spec/coven-automations/v1/state-machines.json`. Clients do not author state; transitions are committed by command handlers or the scheduler, never by arbitrary client writes. + +### Occurrence + +```text +planned -> eligible -> claimed -> dispatching -> running + -> succeeded | failed | cancelled | timed_out | recovery_required + +planned/eligible -> skipped | superseded | cancelled +claimed/dispatching with expired evidence -> recovering -> failed | recovery_required +recovery_required -> failed | dispatching (explicit operator recovery only) +``` + +Terminal: `succeeded, failed, cancelled, timed_out, skipped, superseded`. Non-obvious ratifications: + +- `dispatching -> failed` exists for deterministic pre-side-effect launch refusals (`launch_refused`); without it, every refused launch would be ambiguous, which is wrong — the #816 runner already treats a launch error as a recorded failure with a reason (`runner.rs`). +- `recovering` is automatic reconciliation in progress; `recovery_required` needs an explicit operator command. `recovery_required` is deliberately **not terminal**: its only exits are `failed` (operator determines no side effects were possible) or `dispatching` (operator-approved recovery attempt, which creates a new attempt carrying `priorDisposition: ambiguous`). +- `skipped` vs `superseded`: skipped means policy disposed of the slot (overlap forbid, paused, invalid); superseded means a newer definition revision replaced this one before it was claimed. + +### Attempt + +```text +adopted -> dispatching -> started -> observing + -> succeeded | failed | cancelled | timed_out | ambiguous +``` + +`ambiguous` is terminal for the attempt (dispatch sent but no deterministic ack, or evidence lost). The occurrence carries the recovery; the attempt never re-opens. `dispatching -> failed` covers deterministic launch refusal; `dispatching -> ambiguous` covers unconfirmed dispatch. + +### Run + +```text +accepted -> running -> succeeded | failed | cancelled | timed_out | ambiguous +``` + +`accepted` commits before any consequential side effect. A run spans its attempts: retry/recover increments `attemptCount` and swaps `currentAttemptId`; terminal `terminalDisposition` is written exactly once. + +### Definition + +```text +draft -> paused -> active -> paused | disabled | invalid | tombstoned +disabled -> paused (explicit re-enable only) +invalid -> paused (after a repairing revise) | tombstoned +tombstoned (terminal) +``` + +`draft` replaces the #816 implicit "created PAUSED" default (every import lands in `draft` until validated and explicitly paused/activated). Tombstoning is a deletion marker on the definition plus retention of all history. + +### Invariants + +`state-machines.json` carries the machine-readable form. The ten normative invariants, each traceable to a current-code motivation: + +1. **Command adoption commits before consequential side effects** — closes the gap where `coven.automations.run` launches before any adoption record exists (`runner.rs` fences and launches in one call with no command record). +2. **One occurrence fence cannot own two accepted runs** — generalizes the compare-and-set claim in `claim_due_occurrence` (`occurrences.rs`) from claiming to acceptance. +3. **One attempt cannot bind two runtime sessions** — a second bind is `ILLEGAL_TRANSITION`, never an overwrite. +4. **Terminal states do not regress** — #816 already enforces a weak form (`settle_occurrence` only settles claimed/running; `record_run_finish` only finishes `running` rows); the contract ratifies it for every state. +5. **Absence of runtime evidence cannot become success** — #816's `run_routine_now` marks `succeeded` on a bare `launch_session` ack with no completion evidence (`runner.rs`); under the contract that launch ack is `dispatching -> started`, and settling `succeeded` requires verified evidence. +6. **Cancellation is a request until acknowledged/reconciled** — typed `CANCEL_PENDING` while a running attempt has not acknowledged. +7. **Retry creates a new attempt and never rewrites the prior attempt** — attempts are immutable after settling. +8. **Ambiguous mutating work is not automatically retried** — only `occurrence.recover.v1` with an explicit operator determination opens work after `ambiguous`. +9. **Definition revision changes never rewrite historical occurrences/runs** — every record pins `automationRevision` + definition digest. +10. **Deleting a definition tombstones it without erasing required history** — replaces `delete_definition`'s hard `DELETE FROM automation_definitions` (`store.rs`), which erases identity while leaving orphan occurrences. + +## Commands, idempotency, and revision semantics + +Specified by `command-envelope.schema.json`. Every command is an envelope: + +```json +{ + "schemaVersion": "coven.automations.v1", + "command": "definition.revise.v1", + "adoptionKey": "adopt:revise-daily-notes-0002", + "expectedRevision": 2, + "origin": { "principal": { "principalId": "principal:tim" }, "channel": "sdk" }, + "intent": { "statement": "Move the daily notes slot to 10:00." }, + "payload": { "definition": { } } +} +``` + +- **`adoptionKey` (required):** the stable request/adoption key. First commit wins; the key is stored with the committed outcome. A repeat with the same key returns the first committed outcome unchanged (`outcome: "replayed"` with `replay.firstCommittedAt`) — identical bytes, no second event, no second revision. A repeat with the same key but different command/payload is `ADOPTION_REPLAY_MISMATCH` (409) carrying what the key actually committed, so callers reconcile instead of guessing. Recommendation: keys are caller-chosen ULIDs; handlers may derive per-attempt keys deterministically (`adopt::`). +- **`expectedRevision`:** required for `definition.revise/activate/pause/disable/tombstone`, forbidden otherwise (schema-enforced). Commit happens only when the stored revision equals `expectedRevision`; mismatch returns `REVISION_CONFLICT` (409) with `currentRevision`, committing nothing. +- **`origin`:** authenticated principal, channel, authentication class, requested-at, correlation id. Transports that cannot authenticate must refuse upstream; the envelope records, never decides. +- **`intent.statement`:** explicit human-authored intent, recorded on events and receipts. + +Command catalog (all names versioned in the envelope): create, revise, activate, pause, disable, tombstone; run now; cancel occurrence/run/attempt; retry with explicit prior disposition; recover with explicit evidence determination; list/get/history/health; events read/subscribe; legacy import. The response is one of `committed`, `replayed`, `rejected` — with `result` only on committed/replayed and `error` only on rejected. + +**Idempotency storage note for implementers:** the adoption key must be persisted in the same transaction as the state change it drives (a `command_adoption` table keyed by adoption key storing the serialized committed response), so replays are answerable without recomputation. + +## Errors and status mapping + +Specified by `error-envelope.schema.json`. Domain failures are errors; a rejected or failed domain operation MUST NOT be wrapped as `accepted: true` merely because routing succeeded. This is the direct fix for the current `automation_event` behavior (`control_plane.rs`) that emits `ok/accepted/completed` around `{"error": ...}` payloads. + +Twenty typed codes with a frozen HTTP mapping (also machine-readable in the schema): validation and schema-version refusals → 400; not found → 404; tombstoned → 410; adoption/revision conflicts, cancel-pending, overlap, out-of-order stream → 409; capability, transition, retry-disposition, ambiguous-retry refusals → 422; authority/approval → 403; payload too large → 413; concurrency → 429; deadline → 504; internal → 500. + +Control-action transport mapping: `POST /api/v1/actions` (`crates/coven-cli/src/api.rs`, `route_action`) keeps its `ControlActionResponse` shape but the automations actions MUST surface domain failures as `ok: false, accepted: false, status: rejected` with the typed error envelope embedded, and the transport status MUST be the mapped one — exactly what `rejected_action` already does for routing failures. The same command handlers back both transports and return the same typed outcomes; the router stops assembling domain payloads (its current payload builders move into the shared handlers). + +## Event/changefeed + +Specified by `event-envelope.schema.json`. + +- **Envelope:** `schemaVersion`, `eventId` (globally unique), `stream {kind, id}`, gapless `sequence` per stream, `recordedAt`/`observedAt`, `producer`, optional `causation` (adoption key, cause event id, correlation id), object ids as applicable, `kind`, user-safe `summary` (no secrets, no prompts), typed `payload`, `privacy`, optional `integrity`. +- **Streams:** `automation/{id}`, `occurrence/{id}`, `run/{id}`, plus a global `feed`. Stream-local sequences are gapless and append via compare-and-set; out-of-order appends are refused (`STREAM_OUT_OF_ORDER`), never reordered. +- **Delivery:** at-least-once. Consumers deduplicate on `eventId` and refuse regressions against their cursor (the golden vectors pin both). +- **Read:** `events.read.v1` with `after` (exclusive sequence) or `from` (timestamp, resolved to a concrete cursor in the response); `events.subscribe.v1` with an opaque `checkpoint`. Expired checkpoints return `CURSOR_EXPIRED` (410) with the expiry instant — never a silent rewind. +- **Rehydration:** the read model is a fold: dedupe by eventId → apply strictly-increasing sequences → final state. Reconnection and duplicates converge to the same state (vector `event-replay-rehydrates-deterministically`). Occurrence records carry `eventWindow` so a reader knows the stream bounds it read. +- **Compaction:** `feed.snapshot` events carry `throughSequence` plus compacted state; consumers fold the snapshot and apply strictly-later events. Retention may compact streams only behind a snapshot. + +## Capability negotiation + +`capabilities.json` lists supported v1 variants (trigger `schedule`, action `familiarInvocation`, policies `misfire.latest`, `overlap.forbid`, `timeout.required`, delivery `outputTarget.atomic`, retention `standard`), an empty `experimental` list, and explicit `refused` entries (`trigger.webhook`, `action.pipeline`, `misfire.backfill`) with reasons. Rules: + +- A definition referencing a variant absent from the producer's supported list MUST be refused with `CAPABILITY_UNSUPPORTED`, naming the variant. Nothing is guessed, defaulted, or silently downgraded — the same fail-closed stance as the #816 RRULE vocabulary gate (`rrule.rs` refuses unsupported frequencies instead of approximating). +- Refusal is per-variant and additive: refusing one variant says nothing about others. +- Unknown values inside a supported variant are still unknown variants. +- The negative path is also a schema property: v1 unions are closed, so an unknown variant fails schema validation before negotiation is even needed; producers that relax schema validation in future profiles still refuse at the capability layer. + +## Canonicalization and digests + +Digests (definition integrity, receipts, event integrity where required) are SHA-256 over RFC 8785 (JCS) canonical JSON: UTF-8, recursively key-sorted, no whitespace, minimal escaping, ES6 number formatting. The golden vectors pin actual digest values computed this way over integer/ASCII-only fixtures, so any conformant JCS implementation reproduces them byte-for-byte. Producers MUST NOT digest ad-hoc serializations. + +## Migration from #816 + +Non-destructive, no data loss, no rewritten history: + +1. **Definitions:** on first contract adoption, each stored `automation_definitions` row gains sidecar columns (`revision` = 1, `integrity` = digest over its existing `definition_json` bytes, lifecycle mapping `ACTIVE → active`, `PAUSED → paused`, default `draft` for import). `definition_json` bytes stay byte-identical — the digest is computed over them, not written into them — so pre-migration rows remain verifiable. +2. **Occurrences:** every existing row pins `automationRevision: 1` plus the definition digest; `attempt` counter maps to fence `generation` (claim already increments it in `claim_due_occurrence`); state strings map 1:1 (`planned/claimed/running/succeeded/failed`) with `succeeded/failed` becoming the v1 terminals of the same names. +3. **Runs:** `automation_runs` rows map to v1 runs with `state` from `status`; the ledger's `exit_code/log_json/output_commit` columns carry into `terminalDisposition`/`delivery` without backfilling receipts — receipts exist only for runs that produce them after adoption (receipts are never fabricated for history). +4. **Wire compatibility:** the legacy control actions (`coven.automations.*`, `control_plane.rs`) continue to respond during migration, each response additionally carrying the contract profile; new commands are additive. `coven.automations.import` maps to `legacy.import.v1` (`source: codex-automation-toml`), keeping the non-destructive, created-PAUSED/draft semantics of `import_legacy.rs`. +5. **Nothing is deleted:** no definitions, occurrences, or run history are erased at any step (acceptance criterion), and the migration is idempotent (re-running adopts nothing twice — the adoption table marks it). + +## Implementation boundaries + +- `crates/coven-cli/src/automations/**` may implement the contract but is not its specification; this directory plus `spec/coven-automations/v1/` is the specification. +- Control actions and any HTTP routes delegate to the same command handlers and return the same typed outcomes; the router stops being a payload assembler. +- Cave, SDK, and Psyche consume the pinned artifacts (`schemas`, `test-vectors.json`, `coven.automations.v1.d.ts`) as packed/released artifacts — never source-relative imports, never hand-maintained parallel types. +- This protocol does not move schedule authority into Psyche or authority semantics into Cave: it binds references (`principalId`, `approvalPolicyRef`, `familiarId`) and defers semantics to their canonical layers. + +## Corresponding Rust types (pinned mapping, implementation deferred) + +The Rust projection is mechanical and lands in a follow-up implementation PR (this issue specifies; it does not implement): a `contract` module with serde types renamed to camelCase (`#[serde(rename_all = "camelCase")]`, the existing wire style in `definition.rs`), where each struct maps 1:1 to a schema (`AutomationDefinition`, `AutomationOccurrence`, `AutomationRun`, `AutomationAttempt`, `AutomationReceipt`, `CommandEnvelope`, `CommandResponse`, `ErrorEnvelope`, `EventEnvelope`), `#[serde(deny_unknown_fields)]` on every v1 struct to mirror `additionalProperties: false`, `serde_json::Value` for the extension bag, and round-trip tests generated from the golden vectors. Status enums map to the schemas' enums exactly; the schema files remain the source of truth. + +## Verification matrix + +| Issue requirement | Contract artifact | Required test suite | +| --- | --- | --- | +| Schema validation + Rust round-trip | all schemas | `schema-validation`, `rust-round-trip` | +| State-machine invariant preservation | `state-machines.json` | `state-machine-property-tests` | +| Adoption replay/conflict | command envelope semantics | `request-adoption-replay-and-conflict` | +| Expected-revision conflict | command envelope | `expected-revision-conflict` | +| Duplicate/out-of-order replay | event envelope + vectors | `duplicate-and-out-of-order-event-replay` | +| Typed transport/domain error mapping | error envelope + status mapping | `typed-transport-domain-error-mapping` | +| Golden vectors runnable outside the Coven crate | `test-vectors.json` (self-contained, digest recipe inline) | `golden-vectors-external-runners` | +| Packed/released artifact tests + cross-repo canaries | pinned `.d.ts` + schemas + vectors | `packed-artifact-canaries` (Coven, SDK, Cave pin exact artifacts) | +| #816 migration | migration section above | migration proof: pre/post digest equality, row counts, no deletes | + +All suites are enumerated with `releaseState: proposed` in `conformance-manifest.json`. + +## Alternatives considered (recommendations for the maintainer) + +1. **Tolerant reader vs fail-closed unknown fields.** Chosen: fail-closed (`additionalProperties: false`) with an explicit namespaced extension bag, matching `spec/device-pairing/v1` ("unknown required restrictions fail closed") and the security-first posture of the repo. Alternative considered: ignore unknown fields for additive ease — rejected because silent reinterpretation is exactly the drift this issue exists to end; additive fields still land via a minor profile with dual-emit. +2. **`recovery_required` terminal vs non-terminal.** Chosen non-terminal with two explicit exits (`failed` or a new operator-approved attempt), because the issue requires retry/recover commands with explicit prior disposition while also requiring that ambiguous work is never auto-retried; a terminal `recovery_required` would make explicit recovery impossible without violating "retry creates a new attempt". Alternative considered: model recovery as a new occurrence — rejected because it forks the audit trail for one logical slot. +3. **Revision integer vs content-addressed definition ids.** Chosen monotonic integer + digest (the digest already gives content addressing); integer revisions give callers a trivial compare-and-set and match the store's row model. Alternative considered: content-hash identity — rejected as the primary key (history joins become opaque), kept as the integrity layer. +4. **RFC 8785 vs a Coven-private canonical form.** Chosen JCS: portable, implemented everywhere, and sufficient for v1's integer/ASCII digest fixtures. Alternative considered: a private canonicalization — rejected; it would force every canary to import Coven code, violating the independence requirement. +5. **Attempt-level retry within a run vs run-per-attempt.** Chosen retries-within-a-run (`attemptCount`, `priorDisposition`), matching the issue's wording ("current attempt", "retry creates a new attempt"). Alternative considered: a new run per retry — rejected; it would fragment authority bindings the run is supposed to pin. +6. **`cancelled` as request vs state.** Chosen: the request lives in `cancellation` metadata with `requestedAt`; `cancelled` is only committed on acknowledgment/reconciliation, keeping `CANCEL_PENDING` representable. Alternative considered: immediate cancelled state — rejected; it would let a client-authored transition lie about runtime state. + +## Non-goals + +- Implementing every future trigger or action variant (v1 ships `schedule` + `familiarInvocation` only). +- A general-purpose workflow language (no pipelines, no step graphs). +- Client-authored run state (clients issue commands; the authority commits transitions). +- Defining familiar identity or authority semantics independently of their canonical layers (this protocol binds references only). diff --git a/docs/architecture/mobile-device-pairing-delivery-plan.md b/docs/architecture/mobile-device-pairing-delivery-plan.md index cbfc0a17..ca25cdcb 100644 --- a/docs/architecture/mobile-device-pairing-delivery-plan.md +++ b/docs/architecture/mobile-device-pairing-delivery-plan.md @@ -29,6 +29,8 @@ Exit criteria: language-level tests and cross-implementation vectors pass. ### PR 3 — TUI enrollment and device administration +Detailed slice plan: [`mobile-device-pairing-tui-bootstrap-plan.md`](mobile-device-pairing-tui-bootstrap-plan.md) (issue #785). + - `coven device pair` - scope selection and permission preview - terminal QR plus copyable fallback diff --git a/docs/architecture/mobile-device-pairing-tui-bootstrap-plan.md b/docs/architecture/mobile-device-pairing-tui-bootstrap-plan.md new file mode 100644 index 00000000..7c14d65e --- /dev/null +++ b/docs/architecture/mobile-device-pairing-tui-bootstrap-plan.md @@ -0,0 +1,777 @@ +# Coven TUI QR Bootstrap and End-to-End Encrypted Mobile Pairing — Plan + +Status: Proposed plan (implementation has not started) +Tracks: #785 (parent architecture #784) +Governing protocol contract: [`mobile-device-pairing-v1.md`](mobile-device-pairing-v1.md) +Elaborates: PR 3 ("TUI enrollment and device administration") and the rendezvous slice of PR 4 in the [delivery plan](mobile-device-pairing-delivery-plan.md) + +## 1. Purpose and scope + +This plan turns the pairing protocol contract into a concrete, reviewable +implementation plan for the first-time mobile enrollment UX: + +```text +$ coven device pair → capability preview → QR → E2EE handshake over a +rendezvous relay → six-word phrase confirmed on both endpoints → scoped, +revocable device grant +``` + +In scope: + +- the `coven device` command family (`pair`, `pair --scope`, status, cancel, + device administration entry points); +- a canonical, versioned pairing offer encoded in deterministic CBOR and + carried by a Universal Link plus the existing custom-scheme URL; +- an authenticated, forward-secret Noise handshake between TUI host and mobile + device, with a transcript that binds the offer, keys, capabilities, nonces, + endpoint identities, and protocol versions; +- a rendezvous/relay MVP so pairing works across NAT, SSH hosts, and + restrictive networks using outbound connections from both endpoints; +- human verification (short authentication phrase) and explicit grant + confirmation; +- the adversarial test matrix (replay, substitution, MITM, downgrade, relay, + malformed input). + +Out of scope (separate delivery-plan PRs, linked where they touch this plan): + +- returning-device reconnection, local discovery, and push wake-up + (delivery-plan PR 6); +- recovery, trusted-device introduction, and attestation (PR 7); +- the mobile client (Pocket) UI and platform key storage internals (PR 5); +- production rendezvous fleet operations beyond the single-reference relay. + +The QR is an out-of-band introduction, never a reusable login token. Every +requirement in this plan defers to [`mobile-device-pairing-v1.md`](mobile-device-pairing-v1.md) +where the two disagree; conflicts should be resolved by amending that contract. + +## 2. Current state and gap analysis + +The mobile track already shipped a working, memory-scoped pairing flow. The +plan extends it; nothing here starts from zero. + +### 2.1 What exists today + +| Capability | Current implementation | Code path | +| --- | --- | --- | +| Terminal pairing command | `coven memory mobile pair` renders a QR invitation, polls status, and asks the operator to confirm a six-word phrase | `crates/coven-cli/src/mobile_memory/mod.rs` (`run_pair`, `run_pair_unix`), command enum `MobileMemoryCommand` in `crates/coven-cli/src/main.rs` | +| Pairing engine | Single-use nonce, expiry pruning, host+device phrase confirmation, idempotent completion, bounded retry windows | `crates/coven-cli/src/mobile_memory/pairing.rs` (`PairingManager`, `PendingPairing`, `PairingError`) | +| Pairing v2 offer | `coven-memory://pair` URL with versioned fields and a canonical offer digest over length-prefixed fields | `crates/coven-cli/src/mobile_memory/pairing.rs` (`build_pairing_url`, `PairingOfferV2::hash`), contract in [`docs/design/mobile-pairing-protocol-v2.md`](../design/mobile-pairing-protocol-v2.md) | +| Transcript binding v2 | Offer digest, selected/supported versions, device key, device name, and app version bound into a digest that derives the six-word phrase | `crates/coven-cli/src/mobile_memory/pairing.rs` (`PairingTranscript::V2`), fixture `crates/coven-cli/tests/fixtures/mobile-pairing-v2/transcript-vector.json` | +| QR rendering | Unicode half-block rendering of the pairing URL plus a printed copyable URL and expiry line | `crates/coven-cli/src/mobile_memory/pairing.rs` (`render_pairing_invitation`), `qrcode` crate 0.14 in `crates/coven-cli/Cargo.toml` | +| Device grant model | Versioned grant object with scopes, restrictions, assurance levels, audience, and exact-action intents | `crates/coven-cli/src/mobile_memory/grant.rs` (`DeviceGrant`, `DeviceScope`, `AssuranceLevel`, `DeviceActionIntent`) | +| Request authentication | Canonical signed requests with timestamp, nonce, and body digest; replay window; per-device rate limiting | `crates/coven-cli/src/mobile_memory/auth.rs` (`canonical_request`, `MobileAuthenticator`) | +| Host identity | Stable P-256 host key, self-signed certificate, SHA-256 public-key fingerprint pinned in the QR | `crates/coven-cli/src/mobile_memory/identity.rs` (`load_or_create_host_identity`, `HostIdentity`) | +| Mobile gateway | Private-network rustls TLS listener with bounded routes, body caps, and inflight-connection limits; 5-minute pairing lifetime | `crates/coven-cli/src/mobile_memory/gateway.rs` (`MobileRoute`, `PAIRING_LIFETIME`) | +| Device registry | Atomically persisted, privacy-guarded device records with revocation | `crates/coven-cli/src/mobile_memory/registry.rs` (`DeviceRegistry`, `DeviceRecord`) | +| Audit events | Structured pairing/authentication/revocation audit records | `crates/coven-cli/src/mobile_memory/audit.rs` (`MobileAuditEvent`) | +| Rendezvous relay | Standalone bounded opaque WebSocket room relay (one `host` + one `client`, constant-time credential check, frame/idle/queue caps) — not yet used by the CLI | `crates/coven-relay/src/main.rs`, `crates/coven-relay/src/ws.rs` | +| Protocol contract and schemas | v1 protocol contract, diagnostic JSON schemas, domain-separation and conformance notes | [`mobile-device-pairing-v1.md`](mobile-device-pairing-v1.md), `spec/device-pairing/v1/*` | +| Accepted architecture | Trust-chain decision record that explicitly extends `coven-cli::mobile_memory` | [`docs/design/mobile-device-trust.md`](../design/mobile-device-trust.md) | + +### 2.2 Gap against issue #785 + +| Issue requirement | Today | Gap | +| --- | --- | --- | +| `coven device pair` / `--scope` | `coven memory mobile pair` with a fixed `memory_read` scope (`PAIRING_SCOPE_MEMORY_READ` in `pairing.rs`) | New top-level `device` command family; selectable, previewed scopes | +| Canonical CBOR offer + compact URL-safe encoding | URL query members (JSON-flavored, not CBOR) in `build_pairing_url` | Deterministic CBOR offer and base64url encoding per §5 | +| Universal Link/App Link | `coven-memory://pair` custom scheme only | HTTPS Universal Link carrying the offer in a fragment per §5.4 | +| Forward-secret E2EE handshake (Noise) | TLS 1.3 transport plus phrase confirmation; no application-layer AKEX, no session keys | Noise_XK handshake per §6; new crypto dependencies | +| Rendezvous for cross-network pairing | Gateway requires the phone to reach the host's advertised HTTPS endpoint | Relay session derived from the offer per §7; `coven-relay` already provides the room semantics | +| Countdown / status / cancel | Expiry printed once; Ctrl-C cancels the CLI loop only | Live countdown, explicit status/cancel commands, both-endpoint cancel per §8/§10 | +| Offer-bound capability approval | Phrase binds a fixed scope string | `requested_capabilities_hash` over the exact selected scope set, bound into offer and transcript per §5/§6/§11 | +| Short authentication phrase | Already implemented (six words, 2,048-word list, 66 bits) in `pairing.rs` | Re-derive from the Noise handshake hash per §9; keep six words | +| Bounded failed attempts | One enrollment attempt consumes the nonce (`pairing.rs` `enroll`); phrase failures destroy pending pairings | Add a bounded handshake-attempt counter per §8.4 | +| Replay, substitution, MITM, downgrade, relay, and malformed-input tests | Strong coverage for phrase and nonce paths in `pairing.rs` tests; no relay or handshake tests | Test matrix in §12 | + +The mobile gateway, grants, registry, and audit survive unchanged as the +authority plane; this plan adds a transport and handshake layer in front of +them. + +## 3. Target experience (golden example) + +```text +$ coven device pair --scope sessions.metadata.read,messages.send + +Pair OpenCoven Mobile +Requesting: + ✓ View sessions (sessions.metadata.read) + ✓ Send messages (messages.send) + ✕ Execute tools without approval (not requested: tool_execution_approve) + ✕ Export identity or memory (never grantable over pairing) + +[ QR CODE ] + +Link (if you cannot scan): https://pair.opencoven.ai/p# +Expires in 01:47 +Status: waiting for device · [c] cancel +``` + +After the device connects and the handshake completes, both endpoints display +the same six words; the host confirms only on an exact match: + +```text +Device "Val's iPhone" (app 1.0.0) requests the scopes above. + +Compare these words with the device: +1. willow 2. cinder 3. moon 4. harbor 5. linen 6. ridge + +[c]onfirm / [r]eject: c +Device enrolled. Grant id: 9f14... (revocable with `coven device revoke`) +``` + +The six-word phrase is the existing v2 mechanism (`phrase_for_hash` in +`pairing.rs`); the issue's three-word example (`willow-cinder-moon`) is +illustrative. Six words from a 2,048-word list carry 66 bits, which keeps the +phrase the strong second factor it is today; §15.5 recommends keeping six. + +## 4. Design overview + +### 4.1 Components and data flow + +```text +┌────────────────────── TUI host (coven device pair) ─────────────────────┐ +│ CLI: capability preview, QR render, countdown, confirm/cancel │ +│ │ local unix-socket control API (existing daemon) │ +│ Daemon: PairingSession authority │ +│ · offer minting (CBOR), session store, attempt bounds │ +│ · Noise responder (X25519 static = host pairing key) │ +│ · grant issuance via mobile_memory::grant, registry, audit │ +└───────┬─────────────────────────────────────────────────┬───────────────┘ + │ outbound WSS (rendezvous) │ optional direct + ▼ ▼ LAN TLS (existing) +┌─────────────────────── rendezvous relay ────────────────┐ gateway path +│ coven-relay: opaque room match + ciphertext forward │ +│ no plaintext, no keys, no authority │ +└───────▲─────────────────────────────────────────────────┘ + │ outbound WSS +┌───────┴────────────── Mobile device ────────────────────┐ +│ scan QR / open Universal Link → offer validation │ +│ Noise initiator, enrollment request signature, │ +│ phrase confirmation, grant receipt │ +└─────────────────────────────────────────────────────────┘ +``` + +Both endpoints make outbound connections only. The relay matches opaque room +identifiers and forwards binary frames; it never receives application +plaintext, keys, or grants. This is the delivery-plan PR 4 behavior applied to +pairing first. + +### 4.2 What stays, what changes + +Stays (authority plane unchanged): + +- `mobile_memory::grant` issuance/verification semantics, scope vocabulary, + assurance levels, and exact-action intents (`grant.rs`); +- the device registry, revocation, and audit surfaces (`registry.rs`, + `audit.rs`); +- the direct-LAN TLS gateway as the high-bandwidth path after pairing + (`gateway.rs`), with the phrase/handshake replacing "trust the LAN"; +- request authentication for post-pairing API calls (`auth.rs`). + +Changes (new or extended): + +- a new `device` command family in `crates/coven-cli/src/main.rs` (§10); +- a new pairing-session authority module (proposed + `crates/coven-cli/src/device_pairing/`) that owns offers, the Noise + handshake, and relay transport, and hands confirmed enrollments to + `mobile_memory::grant` + `registry`; +- CBOR offer encoding and Universal Link rendering (§5); +- new crate dependencies: `snow` (Noise), `x25519-dalek`, `hkdf`, `ciborium` + or `serde_cbor`-successor for deterministic CBOR (§15.2); +- `coven-relay` gains nothing conceptually — the CLI becomes its second + consumer; only small additions for derived-room validation if needed (§15.6). + +## 5. Pairing offer (version 1, deterministic CBOR) + +The offer follows the contract's `PairingOffer` (mobile-device-pairing-v1.md, +"Pairing offer") with concrete encodings. The existing v2 URL offer remains +accepted for one deprecation window (§15.7). + +### 5.1 Canonical CBOR layout + +Deterministic CBOR per RFC 8949 §4.2.1 (core deterministic encoding): map +keys in bytewise lexicographic order, shortest-form integers, no indefinite +lengths. All bstr fields are fixed length, so no length ambiguity exists. + +| Field | CBOR key (text string) | Type | Notes | +| --- | --- | --- | --- | +| version | `"v"` | uint (1) | Offer format version; handshake protocol version negotiated separately (§6) | +| pairing_session | `"s"` | bstr 32 | Cryptographically random, single-use session id (room derivation input, §7.1) | +| ephemeral_public_key | `"k"` | COSE_Key map | X25519 host ephemeral key for this pairing attempt; fresh every attempt (issue checklist item 1) | +| host_static_key_id | `"hf"` | bstr 32 | SHA-256 fingerprint of the host's X25519 pairing static key; the QR pin (§6.2) | +| rendezvous_hint | `"r"` | array of maps | Ordered transport hints (§7.2) | +| local_discovery_hint | `"d"` | tstr, optional | Opaque rotating local-discovery token; omitted in the MVP | +| requested_capabilities_hash | `"c"` | bstr 32 | SHA-256 over the canonical CBOR array of selected scope strings (§11.2) | +| expires_at | `"e"` | uint | Unix seconds; host rejects use after expiry (5-minute default, matching `PAIRING_LIFETIME` in `gateway.rs`) | + +An offer is ~170 bytes in CBOR (~230 base64url characters), well inside QR +byte-mode capacity at ECC level M. + +Forbidden in the offer (contract "MUST NOT" list, enforced by schema and +review): permanent API or bearer credentials, owner/familiar/installation +private keys, biometric material, hardware serials or advertising IDs, and +unnecessary identity metadata (no device name, owner name, or account id). + +### 5.2 TypeScript types (diagnostic/tooling form) + +Mirrors `spec/device-pairing/v1/pairing-offer.schema.json` (diagnostic JSON is +for tooling only; the wire format is CBOR): + +```ts +export interface PairingOfferV1 { + version: 1; + /** 32-byte cryptographically random single-use session id, base64url. */ + pairingSession: string; + /** COSE_Key (kty OKP, crv X25519), fresh per pairing attempt. */ + ephemeralPublicKey: CoseKey; + /** SHA-256 fingerprint of the host's X25519 pairing static key, base64url. */ + hostStaticKeyId: string; + rendezvousHints: RendezvousHint[]; + localDiscoveryHint?: string; + /** SHA-256 over canonical CBOR of the selected scope string array. */ + requestedCapabilitiesHash: string; + /** Unix seconds. */ + expiresAt: number; +} + +export interface CoseKey { + kty: "OKP"; + crv: "X25519"; + x: string; // base64url 32 bytes +} + +export interface RendezvousHint { + transport: "wss" | "https" | "local"; + endpoint: string; + priority?: number; // 0..255, lower is preferred +} +``` + +The diagnostic JSON schema gains the same fields (`spec/device-pairing/v1/ +pairing-offer.schema.json` already carries `rendezvous` and +`requestedCapabilitiesHash`; add `hostStaticKeyId`, keep `additionalProperties: +false`). Schema changes land with the implementation PR that emits them. + +### 5.3 Single-use and expiry rules + +- `pairing_session` is 32 bytes from the OS CSPRNG (same generator class as + the existing `begin_pairing` nonce in `pairing.rs`). +- An offer is consumable by exactly one successful handshake. First use pins + the session; second use fails closed (`PairingConsumed` semantics already + proven in `pairing.rs` tests). +- Offers expire after 5 minutes (default; `--ttl` may shorten, never extend, + with a hard maximum of 15 minutes). +- Terminal states destroy all pairing secrets (§8.4). + +### 5.4 Universal Link and QR payloads + +Primary payload — Universal Link with the offer in the URL fragment so +ordinary HTTP request processing never receives it (contract: "A Universal +Link/App Link MAY encode the offer in a URL fragment"): + +```text +https://pair.opencoven.ai/p# +``` + +The fragment never reaches a server; the domain is a routing hint only (same +model as the existing `endpoint` member, which `mobile-pairing-protocol-v2.md` +excludes from the offer digest because the key fingerprint authenticates the +endpoint). The mobile client validates the offer digest and keys locally; +scanning a forged link fails at offer validation. + +Secondary payload — compact custom scheme for terminal copy/paste without a +browser round-trip: + +```text +coven://pair# +``` + +The printed link uses the Universal Link form; both decoders share one CBOR +validator. QR mode: byte mode, ECC level M, quiet zone 4 modules; the +terminal renderer keeps the existing `qrcode` half-block output +(`render_pairing_invitation`) with added blank-line padding and an +`aria`-style plain-text link fallback printed beside it (§10.4). + +## 6. Handshake + +### 6.1 Pattern evaluation + +The contract requires "an established protocol/construction (evaluate Noise +patterns rather than inventing cryptography)". Who knows which static key +before the handshake decides the pattern: + +| Pattern | Initiator (device) knows responder (host) static | Responder knows initiator static | Fit | +| --- | --- | --- | --- | +| Noise_XX | no | no | Works, but the host is authenticated only after the human phrase check; the QR pin is unused cryptography | +| Noise_KK | yes | yes | Fails: first-time enrollment means the host cannot pre-know the device static key | +| Noise_XK | yes (QR-pinned fingerprint) | no (learned encrypted in message 2) | Fits exactly: device authenticates the host cryptographically in message 2, before any human action | +| Noise_IK | yes | yes | Fails like KK | + +**Recommendation: `Noise_XK_25519_ChaChaPoly_SHA256`** with: + +- initiator = mobile device, responder = TUI host (the device scans, the host + answers — matching the offer's direction of trust); +- responder static = a dedicated host **X25519 pairing key** (new; see §6.2), + whose SHA-256 fingerprint is `host_static_key_id` in the offer; +- prologue = the canonical CBOR offer bytes (binds every offer field — + including `requested_capabilities_hash` and expiry — into the handshake + transcript, per issue checklist item 3); +- initiator static = the device's new durable **device identity key**. The + architecture contract separates X25519 agreement from Ed25519 signatures; + the enrollment signature key is Ed25519 (§6.4), and the Noise static binds + the same device identity cryptographically. Implementations generate both + keys at first enrollment and store them together. + +Alternatives considered: + +- **TLS 1.3 + phrase only (today's model):** proven, but there are no + forward-secret application session keys off TLS, no binding of the offer + into a cryptographic transcript beyond the digest, and no protection if the + gateway TLS termination is ever exposed off-LAN; the contract asks for a + Noise handshake. +- **Noise_XX + phrase:** one less host key to manage, but weakens the + QR-pinned host authentication that already exists in v2 + (`fingerprint` member, `mobile-pairing-protocol-v2.md`). +- **Noise_KK with a pre-registered device key:** only applies to re-pairing a + known device; use it later as the reconnection optimization, not first + enrollment. + +The handshake implementation MUST use the `snow` crate (the maintained, +widely reviewed Rust Noise implementation) rather than hand-rolled +Noise state machines (§15.2). + +### 6.2 Host pairing key + +The existing host identity is P-256 and is pinned by TLS certificate +fingerprint (`identity.rs`). Noise needs X25519. Do not convert P-256 keys to +X25519 (non-standard and error-prone); instead: + +- generate a dedicated X25519 host pairing static key on first use, stored in + the same private directory with the same atomic-write and permission + discipline as `identity.rs` (`atomic_create_private`, + `ensure_private_mobile_dir`); +- its SHA-256 fingerprint goes in the offer (`host_staticKeyId`), so a relay + or MITM cannot substitute a host without forging the pinned key; +- rotate it only with an explicit operator action; rotation changes QR + fingerprints and therefore requires a fresh offer — which pairing already + is. + +The host certificate fingerprint mechanism (`identity.rs` `public_key_fingerprint`) +remains for the direct-LAN TLS path and is unchanged. + +### 6.3 Message flow over the rendezvous + +```text +device → relay → host : Noise message 1 (e) [XK: -> e] +host → relay → device: Noise message 2 (e, ee, s, es) [XK: <- e, ee, s, es] +device → relay → host : Noise message 3 (s, se) + enrollment [XK: -> s, se] + : encrypted frames both ways +``` + +- Message 3 carries the first encrypted application payload: the canonical + enrollment request (transcript hash, Ed25519 device public key, requested + scopes digest, device display name, app version, nonce, expiry) signed with + the Ed25519 device key — the contract's "Device enrollment request". +- The host replies with the signed `DeviceGrant` (COSE_Sign1 semantics already + modeled by `DeviceGrant` in `grant.rs`) or a rejection. +- Frames are length-prefixed (u32 big-endian) and capped at the relay's + existing `MAX_FRAME_BYTES` (64 KiB); enrollment payloads are far smaller + than `MAX_MOBILE_REQUEST_BYTES` (64 KiB in `mobile_memory/mod.rs`). +- Key material: Noise chaining key → HKDF-SHA-256 with domain string + `COVEN-PAIR-SESSION/1` splits into the post-handshake transport keys. Both + peers MUST zeroize handshake buffers and ephemeral secrets after use; + `Zeroizing` (already used in `identity.rs`) is the storage discipline. + +### 6.4 Transcript binding (issue checklist item 3) + +The final handshake hash MUST cover, directly or via the prologue and +encrypted payloads: + +1. offer format version and canonical CBOR offer bytes (prologue) — binds + `pairing_session`, ephemeral key, host fingerprint, rendezvous hints, + capabilities hash, expiry; +2. protocol version: Noise protocol name string plus the pairing protocol + range (min/max) from both sides, exchanged inside message 3's encrypted + payload (downgrade detection, §12); +3. host X25519 static (authenticated by XK message 2 against the QR pin); +4. device ephemeral keys (Noise-managed) and device Ed25519 enrollment key + (message 3); +5. the exact requested capability digest, restated inside the signed + enrollment request so the signature covers it independently; +6. fresh nonces from both peers (message-3 payload nonce + the existing + enrollment nonce semantics); +7. both endpoint identity references (host fingerprint, device key digest). + +Any mismatch aborts before any grant exists (contract: "Any mismatch MUST +abort the enrollment"). + +## 7. Rendezvous transport MVP + +### 7.1 Room derivation from the offer + +`coven-relay` rooms are `(32-byte room id, separate bearer credential, one +host + one client)` (`ws.rs`). Derive both from the offer's +`pairing_session` with domain separation: + +```text +room_id = SHA-256("COVEN-RENDEZVOUS-ROOM/1" || pairing_session) → base64url +room_token = SHA-256("COVEN-RENDEZVOUS-TOKEN/1" || pairing_session) → base64url +``` + +Both values are derivable only from the offer, so possession of the QR is the +capability to attempt pairing — which is exactly the threat model: the QR is +a short-lived single-use introduction, and a QR photographed by an attacker +still fails at host authentication (XK), phrase confirmation, and expiry. +The host creates the room (relay "first peer creates the room"); the device +joins as `client`. The relay sees only opaque ids and ciphertext. + +`rendezvous_hint` entries name the relay URL(s), ordered by `priority` +(e.g. `wss://relay.opencoven.ai/ws`). The relay deployment URL is a +maintainer decision (§15.6). A `local` hint may advertise the direct gateway +endpoint for same-network fast paths; discovery is never authentication +(contract §"Local discovery and direct transport"). + +### 7.2 Frame and abuse bounds + +Reuse the relay's existing bounds unchanged (they were built for this): +`MAX_MESSAGE_BYTES` 4 MiB, `MAX_FRAME_BYTES` 64 KiB, 120 s idle timeout, +bounded rooms/channels/queues, one host + one client per room, constant-time +credential comparison (`secret_eq` in `ws.rs`). Host-side additions: + +- at most 3 handshake attempts per pairing session; the session is destroyed + after the bound (§8.4); +- handshake frames must complete within 30 s of room join or the session is + cancelled (stale-session cleanup matching the relay's idle timeout). + +### 7.3 Direct-LAN fallback + +If a `local` hint is present and reachable, the device MAY complete the same +Noise handshake over the existing TLS gateway socket instead of the relay +(transport swap, identical protocol). Changing transport MUST NOT change +endpoint identity or authorization (contract requirement). The MVP ships +relay-first with the direct path opportunistic; if the direct path is +unreachable the relay path is always available — this is what makes pairing +work across different networks (acceptance criterion 1). + +## 8. Enrollment state machine + +Extends the contract's state machine with the host-side session lifecycle. +Invalid transitions fail closed. + +### 8.1 Host (TUI/daemon) + +```text +IDLE ──pair──▶ OFFER_CREATED ──device joined room──▶ RENDEZVOUS_CONNECTED + ▲ │ expired/cancelled │ handshake started + │ ▼ ▼ + │ EXPIRED/CANCELLED HANDSHAKE_ESTABLISHED + │ │ both phrases confirmed + │ any state ──cancel/expiry/failure──▶ CANCELLED/FAILED/EXPIRED + ▼ ▼ +ENROLLED ◀──────────────────────────────────── GRANT_PENDING +``` + +### 8.2 Device + +```text +OFFER_SCANNED ──validate──▶ RENDEZVOUS_CONNECTING ──joined──▶ HANDSHAKE_STARTED + │ invalid/expired │ unreachable (all hints) │ complete + ▼ ▼ ▼ + REJECTED FAILED PHRASE_PENDING ──match──▶ ENROLLED + │ mismatch ×N + ▼ + REJECTED +``` + +### 8.3 Countdown, status, cancel + +- The TUI renders a live `Expires in mm:ss` countdown from `expires_at` and a + one-line status (`waiting for device`, `device connected`, `verifying`, + `enrolled`, `cancelled`, `expired`) — extending the static output of + `render_pairing_invitation`. +- `coven device pair` blocks until a terminal state; `c` or Ctrl-C cancels. +- `coven device status [--json]` reports the active pairing session (state, + remaining seconds, connected device name once known). +- `coven device cancel` cancels the active session from the host. The device + can cancel by closing the relay room or sending the (already encrypted) + `cancel` frame; both destroy the session (acceptance criterion 5). +- The daemon-side control API gains the same endpoints as today's internal + pairing routes (`POST /api/v1/internal/mobile/pairings…` in `mod.rs`), moved + under the device-pairing module with status/cancel verbs. + +### 8.4 Secret erasure and failure bounds (issue checklist items 5–6) + +Every terminal state (`ENROLLED`, `EXPIRED`, `CANCELLED`, `REJECTED`, +`FAILED`) synchronously erases: the pairing session id, host ephemeral +private key, derived transport keys, and the pending-device record — the +`PendingPairing` lifecycle in `pairing.rs` already prunes this way and is the +template. Durables after success are exactly: the device registry record and +its grant (`registry.rs`, `grant.rs`). Nothing else persists. + +Bounded failures: 3 handshake attempts per session (§7.2); a phrase mismatch +before completion destroys the pending pairing (existing behavior, +`incomplete_pairing_mismatch_invalidates_the_retry_window` test); enrollment +nonce single-use (existing behavior, `pairing_nonce_is_consumed_on_first_enrollment_attempt`). + +## 9. Human verification + +- Both endpoints derive the six-word phrase from the **final Noise handshake + hash** (not the standalone transcript digest): HKDF-SHA-256 with info string + `COVEN-PAIR-SAS/1` over the handshake hash, then the existing 66-bit → 6 × + 11-bit word mapping (`phrase_for_hash`, 2,048-word list in + `pairing_words.txt`). Deriving from the handshake hash binds the phrase to + the full E2EE transcript automatically; the word list and rendering stay + identical to today's UX. +- The phrase is displayed on both endpoints after `HANDSHAKE_ESTABLISHED`. + The host requires typed confirmation (or `confirm` on the CLI) before + `GRANT_PENDING` completes; the device requires explicit user confirmation + too (existing two-sided confirmation semantics in `confirm()`). +- The phrase is defense in depth. In Noise_XK the host is already + authenticated by the QR pin; the phrase catches a QR-substitution attack + (attacker swaps the printed QR) which pin-verification alone would also + catch — the phrase additionally catches wrong-endpoint pairing where the + attacker holds a valid relay position but not the device's intent. +- Keep six words (66 bits) rather than the issue's three-word example; + §15.5 records the tradeoff. + +## 10. TUI command surface + +### 10.1 Commands + +```text +coven device pair [--scope LIST] [--ttl SECONDS] [--json] +coven device status [--json] # active pairing session, if any +coven device cancel # cancel the active pairing session +coven device list [--json] # paired devices (wraps mobile registry) +coven device inspect DEVICE_ID # grant details for one device +coven device rename DEVICE_ID NAME +coven device revoke DEVICE_ID # wraps registry revoke + audit event +``` + +`coven memory mobile …` remains as a thin compatibility alias for +enable/disable/status during the transition; new capability lives under +`coven device`. Rationale: the issue's target UX is `coven device pair`, the +delivery plan's PR 3 lists `coven device pair` and +`device list|inspect|rename|revoke`, and today's `MobileMemoryCommand` +(`main.rs`) couples device administration to the memory-gateway feature flag. + +### 10.2 Scope selection and preview (issue: `--scope`) + +- `--scope` accepts a comma-separated list from the `DeviceScope` + vocabulary (`grant.rs`: `memory_read`, `session_metadata_read`, + `conversation_read`, `message_send`, `tool_invocation_request`, + `tool_execution_approve`, `secrets_read`, `familiar_memory_admin`, + `device_admin`, `identity_admin`, `memory_export`, `identity_export`). +- Default (no flag): `session_metadata_read,messages_send` equivalent — the + issue's "view sessions, send messages" preview — never a silent + everything-grant. +- The preview renders each selected scope with ✓, and renders the salient + withheld classes with ✕ (at minimum: tool execution without approval and + any export class), matching the issue's target UX. +- `identity_export` and `memory_export` MUST be rejected by `device pair` in + v1 (they remain registry-manageable for other flows) — the issue's + "Export identity or memory" ✕ line is a hard rule, not styling. +- The exact selected set is hashed into `requested_capabilities_hash` (§5.1) + and bound into the offer digest, the Noise prologue, and the signed + enrollment request (§6.4) — the permission request is cryptographically + bound to what the user approves (acceptance criterion 4). + +### 10.3 Grant issuance + +On successful confirmation, the daemon issues a `DeviceGrant` +(`grant.rs::DeviceGrant::for_device`) with: + +- capabilities = the selected scope set (no broader — contract: "The grant + MUST be no broader than the permissions displayed and approved"); +- audience/restrictions per the existing restriction model (transport + constraint may record `relay` for relay-paired devices); +- the grant id returned to the TUI for the confirmation line and audit event + (`MobileAuditEvent::PairingCompleted` in `audit.rs`). + +### 10.4 QR rendering and accessibility (issue checklist items 4–5) + +- Keep the `qrcode`-crate unicode half-block renderer; add: blank-line quiet + zone, automatic fallback to ASCII (`#`/space) when the terminal reports + non-UTF-8, and a minimum-size check (offer URL ~230 chars → version ~11 QR + at ECC M, still legible at typical TUI widths). +- Always print the Universal Link on its own line for copy/paste (existing + behavior in `render_pairing_invitation`), plus `--json` output carrying + `{link, expiresAt, scopes}` so scripted/assistive clients can surface it. +- Document screen-reader behavior in `coven-docs` (public docs), not here; + this repo carries the contract, the public docs carry the tutorial. + +## 11. Capability mapping + +### 11.1 Contract vocabulary ↔ grant vocabulary + +The contract (mobile-device-pairing-v1.md) uses dotted names; the +implementation uses `DeviceScope` snake_case (`grant.rs`). The mapping is +1:1 and total: + +| Contract | `DeviceScope` | +| --- | --- | +| `sessions.metadata.read` | `session_metadata_read` | +| `conversations.read` | `conversation_read` | +| `messages.send` | `message_send` | +| `tools.request` | `tool_invocation_request` | +| `tools.approve` | `tool_execution_approve` | +| `secrets.read` | `secrets_read` | +| `memory.familiar.read` | `memory_read` (familiar-scoped via restrictions) | +| `memory.familiar.write` | `familiar_memory_admin` | +| `identity.admin` | `identity_admin` | +| `devices.enroll` / `devices.revoke` | `device_admin` | +| `identity.export` | `identity_export` (not pairable in v1) | +| `memory.export` | `memory_export` (not pairable in v1) | + +### 11.2 Capabilities hash + +```text +requested_capabilities_hash = SHA-256(canonical CBOR array of selected + DeviceScope strings, in sorted order) +``` + +Sorted order makes the hash independent of CLI argument order; duplicates are +rejected at parse time (`validate_scope_set` in `grant.rs` already validates +scope sets — extend it with the pairable-subset rule from §10.2). + +## 12. Test plan (issue checklist item 7 + contract "Required security tests") + +New tests live next to the implementation: unit tests in the new +`device_pairing` module, integration tests under `crates/coven-cli/tests/` +(the existing `mobile-pairing-v2` fixture and `pairing.rs` test style are the +template), relay adversarial cases in `crates/coven-relay/src/ws/tests.rs`. + +| Class | Case | Level | +| --- | --- | --- | +| Replay | Offer reuse after success fails (`PairingConsumed`) | unit | +| Replay | Offer reuse after expiry fails | unit | +| Replay | Duplicate enrollment request over a replayed message 3 | integration | +| Substitution | Any offer field change (session, key, capabilities hash, expiry) breaks the prologue → handshake abort | unit | +| Substitution | Device key/name/app-version substitution changes the SAS phrase (extends `pairing_v2_binds_offer_and_client_metadata`) | unit | +| MITM | Relay-position attacker with wrong host static fails at XK message 2 | integration | +| MITM | QR substitution: attacker's offer fails host pin check on device | unit | +| Downgrade | Peer offering min>current or max", + "deviceName": "Val’s iPhone", + "devicePublicKey": "", + "appVersion": "1.0.0", + "supportedProtocol": { "minimum": 1, "maximum": 2 }, + "stepUpAuthorization": { + "publicKey": "", + "assuranceClass": "biometric_only" + } +} +``` + +Rules: + +- The field is optional. Absent ⇒ the transcript digest, phrase, and all v2 + behavior are byte-for-byte identical to today (v1 devices and v2 clients + without step-up are unaffected). +- Present ⇒ the two extra fields are appended to the `COVEN-PAIR/2` transcript + input, changing the transcript digest and therefore the six-word phrase + (`derive_pairing_phrase`, `crates/coven-cli/src/mobile_memory/pairing.rs:542-556`). + Because both endpoints display the phrase derived from the same host-side + transcript, enrollment-time substitution of the step-up key by an attacker + who only photographed the QR changes the phrase and is caught by the human + comparison, exactly like device-key substitution. +- Older hosts reject the unknown member (`deny_unknown_fields`) and fail + closed. That is intentional: a client that requires step-up learns the host + does not support it instead of silently pairing without it. +- The step-up key MUST be a canonical uncompressed 65-byte P-256 X9.63 key, + validated with the same routine as `devicePublicKey` + (`crates/coven-cli/src/mobile_memory/pairing.rs:529-538`), and MUST differ + from the possession key. +- The authorization-key record is persisted in the same critical step that + registers the device and grant (the `both confirmed` branch, + `crates/coven-cli/src/mobile_memory/pairing.rs:330-372`), so a grant is never + issued with step-up requirements that no enrolled key can ever satisfy. + +**Recommendation — enrollment-time proof of possession.** The enrollment +should additionally carry a signature by the step-up private key over +`"COVEN-STEPUP-ENROLL/1\0" || transcript_hash`, verified before the record is +persisted. Rationale: it proves the key exists in the declared policy domain +(producing the signature exercises the platform gate — on iOS, creating a +signature with a biometry-gated key triggers exactly the LocalAuthentication +ceremony the class claims) and rejects mistyped/unusable keys at pairing time +instead of at first sensitive use. Alternatives considered: (a) skip it — +simpler pairing ceremony, but a key that can never sign is only discovered +later (fail-closed, so acceptable, but degrades UX); (b) require it only for +`biometric_only` class. Recommend: require it for all classes in v1; a failed +ceremony at enrollment means the declared class does not match platform policy. + +### Assurance classes and ceilings + +The enrollment declares the platform policy that protects the step-up key. The +class caps what proofs from that key can ever prove (the server-side ceiling in +[effective assurance](#effective-assurance-server-side-never-client-asserted)): + +| Class | Platform enforcement | Ceiling | +| --- | --- | --- | +| `biometric_only` | iOS `deviceOwnerAuthenticationWithBiometrics` on a Secure Enclave key; Android `BiometricPrompt` with `BIOMETRIC_STRONG` only | `FreshBiometric` | +| `user_verification` | iOS `deviceOwnerAuthentication` (biometric or passcode); Android `BIOMETRIC_WEAK\|DEVICE_CREDENTIAL` | `FreshUserVerification` | +| `device_credential` | PIN/pattern/password only (Android `DEVICE_CREDENTIAL` alone; iOS `kSecAccessControlDevicePasscode`) | `FreshUserVerification` | + +`device_credential` is a distinct class, per the issue's Android mapping: a +fresh passcode entry proves fresh *user verification*, never fresh *biometric*. +`AssuranceLevel::RecentUserVerification` is a server-side policy concept +(platform "recently unlocked" state) with no cryptographic proof; it is not a +claimable proof class. `AssuranceLevel::StepUp` remains reserved for the +recovery/other-device flow described in +`docs/design/mobile-device-trust.md` ("Biometrics and step-up authorization") +and is never minted by `COVEN-ASSURANCE/1`. + +### Storage (separate from the possession key) + +Authorization-key metadata lives in its own store, deliberately not in +`devices.json` (`registry.rs:18`), so possession identity and authorization +enrollment have independent lifecycles: + +`~/.coven/mobile/authorization-keys.json` + +```json +{ + "version": 1, + "keys": [ + { + "deviceId": "00000000-0000-0000-0000-000000000001", + "publicKeyX963": "", + "subjectKeyId": "", + "assuranceClass": "biometric_only", + "enrolledAt": "2026-07-29T12:00:00.000Z", + "revokedAt": null, + "keyEpoch": 1 + } + ] +} +``` + +- Written with the same private, atomic-replace discipline as the device + registry (`registry.rs:13` re-exports `config::atomic_replace_private`; + `validate_private_file` on read — `registry.rs:353-407` shows the pattern). +- At most one active (non-revoked) key per device; `subjectKeyId` reuses the + grant's key-id convention — base64url SHA-256 over the canonical public key + (`grant.rs:242-250`). +- The subject/possession key and its `subject_key_id` stay exactly where they + are (`DeviceRecord`, `DeviceGrant`), satisfying "store authorization-key + metadata separately from the device subject/possession key". +- Forgetting or revoking a device (`registry.rs:revoke`, and the + `--forget-devices` path, `mod.rs:65-88`) cascades to its authorization key. + +## Canonical proof bytes (COVEN-ASSURANCE/1) + +Framing follows the repo's canonical-byte conventions: a versioned ASCII domain +terminated by NUL (as `COVEN-ACTION/1\0`, `grant.rs:270`), then each field +framed as an unsigned 32-bit big-endian length followed by its bytes, exactly +like `DeviceActionIntent::canonical_bytes` (`grant.rs:266-284`) and +`update_length_prefixed` (`pairing.rs:503-506`). + +```text +"COVEN-ASSURANCE/1\0" +u32(len) || bytes for each field, in order: + 1. device_id — raw 16-byte UUID + 2. grant_id — raw 16-byte UUID (DeviceGrant::id, v5-derived, + grant.rs:114) + 3. revocation_epoch — unsigned 64-bit big-endian + 4. authorization_key_id — UTF-8 base64url(SHA-256(step-up public key)), + same derivation as grant.rs subject_key_id + 5. context_mode — ASCII "request" or "action" + 6. context_digest — raw 32-byte SHA-256 (defined below) + 7. challenge — raw 32-byte server-issued challenge + 8. issued_at — ASCII RFC 3339 UTC, millisecond precision + 9. expires_at — RFC 3339 UTC, same encoding + 10. requested_assurance — ASCII "fresh_user_verification" or "fresh_biometric" +``` + +The step-up key signs exactly these bytes with ECDSA P-256 over SHA-256, +DER-encoded, base64url — the same signature encoding the possession path +verifies (`auth.rs:239-260`, `Signature::from_der`). + +### Context digest (server-recomputed, never client-asserted) + +`context_digest = SHA-256(canonical_context_bytes)` where the server computes +the bytes itself — the client never sends a digest to trust: + +- **`request` mode** — the exact `COVEN-MEMORY/1` canonical request bytes the + possession key signed for this same request: + `canonical_request(method, path_and_query, timestamp, nonce, body_digest)` + (`crates/coven-cli/src/mobile_memory/auth.rs:34-51`). The proof therefore + covers byte-for-byte the same request the possession signature covers; there + is no gap in which one can be swapped. +- **`action` mode** — `DeviceActionIntent::canonical_bytes()` + (`crates/coven-cli/src/mobile_memory/grant.rs:266-284`, `COVEN-ACTION/1`), + recomputed by the server from the submitted intent. The intent already binds + scope, operation, target, effect digest, nonce, and its own window. + +The `context_mode` field makes the two domains non-substitutable. + +### Validity + +- `expires_at - issued_at ≤ 120` seconds (recommended default 60; the vector + below uses 60). For `action` mode, additionally + `proof.expires_at ≤ intent.expires_at` — the proof window is nested inside + the intent window (intent lifetime is capped at 300 s, + `grant.rs:12`). +- Server clock tolerance: none beyond the checks themselves; `issued_at ≤ now ≤ + expires_at` with `issued_at` within the challenge's own validity window. + +## Challenge issuance and replay protection + +A proof MUST cover a server-issued, single-use challenge: + +- **Issuance.** New possession-authenticated mobile route + `POST /api/v1/mobile/assurance/challenge` (protected exactly like today's + routes: `x-coven-protocol: 1` + `COVEN-MEMORY/1` headers, + `crates/coven-cli/src/mobile_memory/gateway.rs:677-717`). Response envelope + carries `{ "challenge": , "expiresAt": }`. +- **Binding.** The stored record binds `device_id`, `grant_id`, + `revocation_epoch`, `expires_at = issued + ≤120 s`, and `spent = false`. + Grant rotation or revocation immediately invalidates outstanding challenges. +- **Consumption.** Verification atomically flips `spent` under the store lock + before returning success (same single-winner pattern as + `auth.rs::insert_nonce`, `auth.rs:198-217`, including the bounded-map + discipline). A failed signature does not spend the challenge; a successful + one does. Two concurrent submissions of the same proof: exactly one wins. +- **Storage.** `~/.coven/mobile/assurance-challenges.json` — separate from the + request-nonce replay cache in `MobileAuthenticator` (`auth.rs:105`), which + keys `(device_id, request_nonce)` and serves `COVEN-MEMORY/1` replay + protection. Challenge state is persisted (not just in-memory) so a daemon + restart cannot resurrect a spent challenge inside a live proof window. +- **Why a server challenge.** The threat model lists "attacker with temporary + access to an unlocked endpoint" + (`docs/security/mobile-device-pairing-threat-model.md`). A server challenge + bounds pre-minting to one proof per challenge with a ≤120 s horizon — proofs + cannot be banked offline in bulk while the phone is unlocked. Alternative + considered: client-generated nonce + server replay cache (the + `insert_nonce` pattern). Rejected as the default: it permits offline + pre-minting of unlimited proofs while the device is unlocked. It remains a + viable fallback if a zero-round-trip flow is ever required; if adopted, its + replay store MUST still be a separate cache from the request-nonce cache. + +### Verification procedure (normative order) + +Given a possession-authenticated request carrying step-up proof headers: + +1. **Possession first.** The ordinary `COVEN-MEMORY/1` verification must have + succeeded (`gateway.rs:697-717`). A step-up proof is never evaluated for an + unauthenticated or revoked device. +2. **Load the enrolled authorization key** for `device_id` from the + authorization-key store. Absent or revoked → possession-only (or fail + closed, step 8). +3. **Challenge check.** Look up the presented challenge: must exist, belong to + this `device_id` and the grant's current `revocation_epoch`, be unspent, and + be unexpired. Spent/expired/unknown → proof invalid. +4. **Recompute the context digest** from the actual request bytes + (`canonical_request`, `auth.rs:34-51`) or the submitted + `DeviceActionIntent` (`grant.rs:266`). Never trust a client-supplied digest. +5. **Rebuild the canonical bytes** from: registry device id, grant id and + `revocation_epoch` (`registry.rs authorization_record`), the enrolled + `authorization_key_id`, the presented mode/`issued_at`/`expires_at`/ + `requested_assurance`, and the server-recomputed values above. +6. **Verify the signature** against the enrolled step-up public key (DER + P-256, `verify_signature` pattern, `auth.rs:239-260`). +7. **Compute effective assurance** (below) and pass it to + `DeviceGrant::authorize` (`grant.rs:157-194`). +8. **Fail closed:** any failure → effective assurance is `Possession`. The + grant's own policy then decides: if the requested scope requires stronger + assurance (`require_fresh_user_verification_for` or `minimum_assurance`, + `grant.rs:171-192`), `authorize` returns `GrantError::AssuranceRequired` + and the request is rejected — it is not silently downgraded to a weaker + success. + +### Effective assurance (server-side, never client-asserted) + +```text +effective = Possession # default +if the proof verifies end-to-end: + ceiling = class_ceiling(enrolled_key.assurance_class) + requested = parse(requested_assurance) # claim in the signed bytes + effective = min(requested, ceiling) # server caps the claim +# then, exactly as today: +DeviceGrant::authorize(required_scope, effective, now) # grant.rs:157-194 +``` + +- The client's requested level is inside the signature, so relabeling it after + the fact is a signature failure; the server caps it by the enrolled key's + declared class, so even a valid signature cannot mint a class the key's + platform policy does not support ("possession proof cannot be relabeled as + biometric proof" — a possession-key signature never verifies under the + step-up public key, and `COVEN-MEMORY/1` bytes are not `COVEN-ASSURANCE/1` + bytes). +- The existing `Ord` on `AssuranceLevel` (`grant.rs:50-58`) already gives the + right lattice: a `FreshBiometric` proof satisfies a + `FreshUserVerification` requirement. +- `RecentUserVerification` is not cryptographically provable (no ceremony to + sign) and stays a server-side policy notion, out of scope for proofs. +- `ensure_still_active` re-checks must reuse the same effective assurance + value for the request (the current re-check passes `Possession`, + `auth.rs:174-181`; with step-up it must not fail a legitimately + step-up-authorized request). `VerifiedMobileDevice` + (`auth.rs:96-101`) gains an `effective_assurance` field for that purpose. + +### Transport (wire shape) + +Six flat headers, mirroring the existing `x-coven-*` convention +(`gateway.rs:917-929`): + +| Header | Value | +| --- | --- | +| `x-coven-assurance-context` | `request` \| `action` | +| `x-coven-assurance-challenge` | base64url of the 32-byte challenge | +| `x-coven-assurance-issued-at` | RFC 3339 UTC, millis | +| `x-coven-assurance-expires-at` | RFC 3339 UTC, millis | +| `x-coven-assurance-level` | `fresh_user_verification` \| `fresh_biometric` | +| `x-coven-assurance-signature` | base64url DER ECDSA | + +For `action` mode the same headers ride on the request that submits the +`DeviceActionIntent`; the server hashes the intent from that request body. The +action-submission route itself is the #786 exact-action work and is out of +scope here; the proof contract is independent of which route carries it. + +## Rotation and revocation + +Rotation and revocation of the authorization key never change familiar or root +identity (identity separation table, `docs/design/mobile-device-trust.md`, +"Identity and credential separation"): + +- **Rotate** — enroll a replacement key for the device with a new `keyEpoch` + (same transcript-bound ceremony as initial enrollment, plus proof of + possession of the *old* step-up key or fresh possession-key authentication + per owner policy). Exactly one active key per device; the previous record is + retained with `revokedAt` for audit, and outstanding challenges are + invalidated. +- **Revoke the step-up key** — device falls back to possession-only; grants + requiring more fail closed. Does not revoke the device. +- **Revoke the device** — `registry.revoke` (`registry.rs:235-257`) cascades: + possession and step-up both die; the revocation epoch bump invalidates + outstanding challenges. +- **Compromise semantics** — possession-key compromise: revoke the device. + Step-up-key compromise: revoke the key (and re-enroll); a relay/account + compromise mints nothing — assurance requires a signature from a + policy-protected key the attacker never holds. + +## State machines + +```text +Authorization key: absent → enrolled → rotated (epoch+1) → … + ↘ revoked (per-key or device cascade) + +Challenge: issued ── verified+consumed (atomic) ──▶ spent + │ expires_at passed + ▼ + expired (pruned opportunistically, bounded store) + +Proof verification: possession OK + → load key → check challenge → recompute digest → rebuild bytes + → verify signature → check window/lifetime + → effective = min(claimed, class ceiling) + → DeviceGrant::authorize(required_scope, effective, now) + any failure ⇒ effective = Possession; grant policy then decides + (AssuranceRequired ⇒ reject; otherwise proceed) +``` + +## Platform mapping + +### iOS + +- Possession key: Secure Enclave P-256 (`kSecAttrTokenIDSecureEnclave`), no + per-request biometric prompt — keeps reconnect frictionless. +- Step-up key: separate Secure Enclave P-256 key with + `SecAccessControl` `.privateKeyUsage` plus the policy for its class: + `.biometryCurrentSet` (+ `LAContext` `deviceOwnerAuthenticationWithBiometrics` + for the biometric-only ceremony when policy demands biometric rather than + passcode fallback), or `.devicePasscode` for the device-credential class. +- Signature algorithm: the X9.62 message-signature member of the + `kSecKeyAlgorithm` family, `ECDSASignatureMessageX962SHA256` (the two halves + concatenate into the full constant) — DER output, matching the server's + `Signature::from_der` path (`auth.rs:252-259`). + +### Android + +- Hardware-backed Keystore P-256 where available; + `setUserAuthenticationRequired(true)` and + `setUserAuthenticationParameters(...)` / `setUserAuthenticationParameters(…, AUTH_BIOMETRIC_STRONG)` for the step-up key; + `setInvalidatedByBiometricEnrollment(true)` to keep "current biometry" honest. +- `BiometricPrompt` with `BIOMETRIC_STRONG` authenticators for + `biometric_only`; `DEVICE_CREDENTIAL`-only flows enroll as the separate + `device_credential` class — never labeled `FreshBiometric`. +- `Signature.getInstance("SHA256withECDSA")` produces DER — same wire format. + +Both platforms keep the possession key prompt-free and the step-up key +prompt-gated; only signatures cross the trust boundary. + +## Security invariants → mechanism + +| Invariant (issue) | Mechanism | +| --- | --- | +| Biometric material never leaves the OS subsystem | Only P-256 signatures transit; no biometric field exists anywhere in the protocol | +| Possession proof cannot be relabeled as biometric proof | Server computes effective assurance from a verified step-up signature; possession key ≠ step-up key; `COVEN-MEMORY/1` / `COVEN-ACTION/1` / `COVEN-ASSURANCE/1` domains are disjoint | +| Proof for action A cannot authorize action B | `context_digest` covers the exact canonical request/intent bytes, recomputed server-side | +| Proof for device/grant A cannot authorize device/grant B | `device_id` + `grant_id` (+ `revocation_epoch`) inside the signed bytes | +| Relay/account compromise cannot mint fresh-biometric assurance | Assurance requires the enrolled step-up private key; relays never hold it | +| Replayed proofs fail closed | Single-use server-issued challenge, atomically consumed; ≤120 s window; independent of request nonces | +| Self-hosted/unattested clients remain possible | Step-up is optional per grant/owner policy; possession always remains a valid baseline (`DeviceGrantRestrictions` defaults, `grant.rs:80-87`) | + +## Portable golden vector + +Synthetic, no live credential — same convention as +`crates/coven-cli/tests/fixtures/mobile-pairing-v2/transcript-vector.json` and +`crates/coven-cli/tests/fixtures/mobile-memory-v1/signature-vector.json`: every +byte string is documented as hex (the wire encodes challenges, digests, and +signatures as unpadded base64url; the vector stores raw bytes so any +implementation can reproduce them). An implementation PR adds this as +`crates/coven-cli/tests/fixtures/mobile-assurance-v1/assurance-vector.json`; +Swift/Android implementations must reproduce `canonicalProofBytesHex` exactly. +ECDSA P-256 signatures are randomized (`k` is per-signature; neither Secure +Enclave nor Android Keystore exposes deterministic RFC 6979 signing), so +implementations are not expected to reproduce `signatureDERHex` byte-for-byte: +they must **verify** it over `canonicalProofBytesHex` with +`stepUpPublicKeyX963Hex`, and their own signatures must verify the same way. + +```json +{ + "fixtureNotice": "SYNTHETIC TEST KEY — NOT A CREDENTIAL", + "deviceId": "00000000-0000-0000-0000-000000000001", + "grantId": "a67b9d68-b8c8-5a84-923f-3158b93ee261", + "revocationEpoch": 0, + "stepUpPrivateKeyScalarHex": "0202020202020202020202020202020202020202020202020202020202020202", + "stepUpPublicKeyX963Hex": "04550f471003f3df97c3df506ac797f6721fb1a1fb7b8f6f83d224498a65c88e24136093d7012e509a73715cbd0b00a3cc0ff4b5c01b3ffa196ab1fb327036b8e6", + "authorizationKeyIdHex": "fe00ab0f341901f863a49160cf554588d6928282d531b799addc4123f45ce85a", + "contextMode": "request", + "protectedCanonicalRequestHex": "434f56454e2d4d454d4f52592f310a4745540a2f6170692f76312f6d6f62696c652f6d656d6f72792f6f766572766965770a313738353332363430300a414141414141414141414141414141414141414141414141414141414141414141414141414141414141410a3437444551706a38484253612d5f54496d572d354a4365755165526b6d354e4d704a575a47336853754655", + "contextDigestHex": "dde33200a4ad41fa4d11d7f81713f74cdf0ce3971d6a5e5003b43fa789bdc12f", + "challengeHex": "0909090909090909090909090909090909090909090909090909090909090909", + "issuedAt": "2026-07-29T12:00:00.000Z", + "expiresAt": "2026-07-29T12:01:00.000Z", + "requestedAssurance": "fresh_biometric", + "canonicalProofBytesHex": "434f56454e2d4153535552414e43452f3100000000100000000000000000000000000000000100000010a67b9d68b8c85a84923f3158b93ee2610000000800000000000000000000002b5f674372447a515a4166686a704a46677a315646694e6153676f4c564d62655a72647842495f526336466f000000077265717565737400000020dde33200a4ad41fa4d11d7f81713f74cdf0ce3971d6a5e5003b43fa789bdc12f00000020090909090909090909090909090909090909090909090909090909090909090900000018323032362d30372d32395431323a30303a30302e3030305a00000018323032362d30372d32395431323a30313a30302e3030305a0000000f66726573685f62696f6d6574726963", + "signatureDERHex": "3044022035a34c02382512c29d05de88ceaff21b2141d60b592bc4ab2cc511bad976ab3702202f28af09e7c0343606682f927f349e7abf0d31b94346a8b73f0d88a7f4cb2c0c" +} +``` + +`protectedCanonicalRequestHex` decodes to the `COVEN-MEMORY/1` canonical +request from the existing memory-v1 fixture +(`GET /api/v1/mobile/memory/overview`, timestamp `1785326400`, the all-zero +nonce, and the SHA-256 body digest of the empty payload); its SHA-256 is +`contextDigestHex`. Notes for implementers: `grantId` is +`Uuid::new_v5(device_id, "coven-device-grant-v1")` (`grant.rs:114`); the +possession key of the protecting device is the existing memory-v1 vector key +(`signature-vector.json`, scalar `0x0101…`); the step-up key scalar is +`0x02`-repeated (the Rust tests' `public_key(seed)` convention). The signature +is DER-encoded ECDSA/P-256/SHA-256 over the exact `canonicalProofBytesHex`. + +### Schemas + +```typescript +// Client-facing assurance classes (declared at enrollment; platform policy). +type AssuranceClass = "biometric_only" | "user_verification" | "device_credential"; + +// Claim inside the signed bytes; server caps it by class ceiling. +type RequestedAssurance = "fresh_user_verification" | "fresh_biometric"; + +type AssuranceContextMode = "request" | "action"; + +interface StepUpAuthorizationEnrollment { // optional MobilePairingRequest member + publicKey: string; // canonical P-256 X9.63, base64url + assuranceClass: AssuranceClass; + enrollmentSignature?: string; // base64url DER over "COVEN-STEPUP-ENROLL/1" || transcript hash +} + +interface AssuranceProofHeaders { + "x-coven-assurance-context": AssuranceContextMode; + "x-coven-assurance-challenge": string; // base64url 32B, server-issued + "x-coven-assurance-issued-at": string; // RFC 3339 UTC, millis + "x-coven-assurance-expires-at": string; // RFC 3339 UTC, millis + "x-coven-assurance-level": RequestedAssurance; + "x-coven-assurance-signature": string; // base64url DER +} + +interface AssuranceChallenge { + challenge: string; // base64url 32B + expiresAt: string; // RFC 3339 +} + +interface DeviceAuthorizationKeyRecord { + deviceId: string; // UUID v4 + publicKeyX963: string; // canonical P-256 X9.63, base64url + subjectKeyId: string; // base64url SHA-256 over publicKeyX963 + assuranceClass: AssuranceClass; + enrolledAt: string; // RFC 3339 + revokedAt: string | null; + keyEpoch: number; // monotonic per device +} +``` + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "COVEN-ASSURANCE/1 golden vector", + "type": "object", + "additionalProperties": false, + "required": ["fixtureNotice", "deviceId", "grantId", "revocationEpoch", + "stepUpPrivateKeyScalarHex", "stepUpPublicKeyX963Hex", + "authorizationKeyIdHex", "contextMode", + "protectedCanonicalRequestHex", "contextDigestHex", + "challengeHex", "issuedAt", "expiresAt", "requestedAssurance", + "canonicalProofBytesHex", "signatureDERHex"], + "properties": { + "fixtureNotice": { "type": "string" }, + "deviceId": { "type": "string", "format": "uuid" }, + "grantId": { "type": "string", "format": "uuid" }, + "revocationEpoch": { "type": "integer", "minimum": 0 }, + "stepUpPrivateKeyScalarHex": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "stepUpPublicKeyX963Hex": { "type": "string", "pattern": "^04[0-9a-f]{128}$" }, + "authorizationKeyIdHex": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "contextMode": { "enum": ["request", "action"] }, + "protectedCanonicalRequestHex": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "contextDigestHex": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "challengeHex": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "issuedAt": { "type": "string", "format": "date-time" }, + "expiresAt": { "type": "string", "format": "date-time" }, + "requestedAssurance": { "enum": ["fresh_user_verification", "fresh_biometric"] }, + "canonicalProofBytesHex": { "type": "string", "pattern": "^[0-9a-f]+$" }, + "signatureDERHex": { "type": "string", "pattern": "^30[0-9a-f]+$" } + } +} +``` + +## Requirement checklist (issue → section) + +| Issue requirement | Section | +| --- | --- | +| Bind optional step-up key + declared class into pairing-v2 transcript before grant issuance | [Enrollment](#enrollment-binding-the-step-up-key-into-the-pairing-v2-transcript) | +| Store authorization-key metadata separately from possession key | [Storage](#storage-separate-from-the-possession-key) (separate `authorization-keys.json`) | +| Canonical `COVEN-ASSURANCE/1` proof bytes | [Canonical proof bytes](#canonical-proof-bytes-coven-assurance1) | +| Verify signatures against enrolled key; never trust client assurance | [Verification procedure](#verification-procedure-normative-order), [Effective assurance](#effective-assurance-server-side-never-client-asserted) | +| Short validity + replay protection independent of request nonces | [Validity](#validity), [Challenge issuance](#challenge-issuance-and-replay-protection) | +| Effective assurance computed server-side, passed to `DeviceGrant::authorize` | [Effective assurance](#effective-assurance-server-side-never-client-asserted) | +| Absent/invalid/expired/replayed → possession-only or fail closed | [Verification procedure](#verification-procedure-normative-order) step 8 | +| Key rotation/revocation without touching familiar/root identity | [Rotation and revocation](#rotation-and-revocation) | +| Portable vectors for Swift/Android | [Portable golden vector](#portable-golden-vector) | +| Acceptance: `FreshBiometric` grant succeeds only with fresh signature from the enrolled biometric-policy key over the exact context | Verification steps 2–7 + the `biometric_only` ceiling mapping | + +## Recommendations and alternatives considered + +| Decision | Recommendation | Alternatives considered | +| --- | --- | --- | +| Transcript extension shape | Append optional fields to the `COVEN-PAIR/2` transcript when present | Bump to a `COVEN-PAIR/3` domain: cleaner versioning, but forks the phrase derivation for no security gain; the conditional fields are backward compatible (digest unchanged when absent) and both endpoints always agree on the request they hold | +| Server-issued challenge vs client nonce | Server-issued challenge (required) | Client nonce + replay cache: no new route/round trip, but permits bulk pre-minting while a device is unlocked; keep as documented fallback | +| Storage of authorization keys | Separate `authorization-keys.json` | Registry v3 with a nested field: entangles rotation with device-record migrations (`registry.rs:19` is at version 2 today) for no benefit | +| Error surfacing | Add `AssuranceRequired` to `MobileErrorCode` (`contract.rs:53-74`); today `auth.rs:150` maps every `authorize` failure to `DeviceRevoked`, which misreports assurance failures as revocation | Keep mapping into `DeviceRevoked`: breaks clients' ability to prompt for step-up | +| Enrollment-time possession proof of the step-up key | Require a signature over the transcript hash at enrollment (exercises the declared platform gate once, at pairing) | Skip it: pairing stays prompt-free, but an unusable/mismatched-policy key is discovered only at first sensitive use (still fail-closed) | +| New audit events | `StepUpVerified` / `StepUpRejected` in `MobileAuditEvent` (`audit.rs:19-28`) | Reuse `AuthenticationRejected`: loses the distinction operators need to tune step-up friction | +| Implementation home | `crates/coven-cli/src/mobile_memory/assurance.rs` alongside `auth.rs`/`grant.rs` | A new crate: premature until the #787 relay session work forces extraction (`docs/design/mobile-device-trust.md`, "Authority boundary") | + +## Implementation plan (follow-up PRs) + +1. `assurance.rs` — canonical bytes, challenge store, verification, effective + assurance; adversarial tests (tamper every field, replay, cross-device, + cross-grant, expired, absent-key, possession-key-as-step-up). +2. Pairing extension + authorization-key store + cascade revocation. +3. Gateway plumbing: headers, challenge route, error codes, audit events, + `ensure_still_active` effective-assurance reuse. +4. Golden-vector fixture + conformance test; platform notes validated against + the iOS/Android mappings above. + +Every implementation PR in this track passes the repository gates (`cargo fmt +--check`, `cargo clippy --workspace --all-targets -- -D warnings`, +`cargo test --workspace --locked`, secret scan, privacy guard — see +`AGENTS.md`) and may not weaken existing v1 privacy, replay, revocation, +canonicalization, or audit guarantees. diff --git a/docs/design/mobile-device-trust.md b/docs/design/mobile-device-trust.md index 239643f1..c5ed8969 100644 --- a/docs/design/mobile-device-trust.md +++ b/docs/design/mobile-device-trust.md @@ -323,7 +323,7 @@ The migration is additive and staged: - `#785` — versioned pairing offer, transcript hardening, portable test vectors, and E2EE rendezvous handshake. - `#786` — generalized grants, assurance policy, exact-action authorization, device management, and registry migration. - `#787` — relay-first reconnect, local discovery fast path, session resumption, and push-as-wakeup semantics. -- `#788` — trusted-device introduction, passkey/recovery contracts, threshold policy, and optional attestation. +- `#788` — trusted-device introduction, passkey/recovery contracts, threshold policy, and optional attestation; planned in [`mobile-recovery-and-introduction-plan.md`](mobile-recovery-and-introduction-plan.md). ## Merge gates for implementation PRs diff --git a/docs/design/mobile-recovery-and-introduction-plan.md b/docs/design/mobile-recovery-and-introduction-plan.md new file mode 100644 index 00000000..341eb11d --- /dev/null +++ b/docs/design/mobile-recovery-and-introduction-plan.md @@ -0,0 +1,752 @@ +# Mobile Recovery, Trusted-Device Introduction, and Optional Attestation Plan + +**Status:** proposed plan and implementation contract for the `#788` scope of the `#784`–`#788` mobile connection track +**Parent architecture:** [`mobile-device-trust.md`](mobile-device-trust.md) (accepted, `#784`) +**Builds on:** `#786` (device-bound credentials, grants, assurance), `#787` (reconnection, discovery, relay) +**Delivery slot:** PR 7 — "Recovery and trusted-device introduction" in [`../architecture/mobile-device-pairing-delivery-plan.md`](../architecture/mobile-device-pairing-delivery-plan.md) +**Threat model base:** [`../security/mobile-device-pairing-threat-model.md`](../security/mobile-device-pairing-threat-model.md) +**Authority owner:** Coven daemon / Rust authority layer + +## 1. Purpose and scope + +This plan defines the concrete contracts for the `#788` issue: + +1. **Trusted-device introduction** — an already trusted device authorizes a new installation with a fresh step-up verification and a signed enrollment transcript. +2. **Passkey recovery** — optional, account-backed recovery and remote-enrollment paths that never make a cloud account, synced passkey, Apple/Google ecosystem, or platform attestation the canonical OpenCoven/familiar identity. +3. **Optional attestation** — app/hardware assurance represented as policy attributes (`verified_official_app`, `verified_hardware_key`, `unattested_device`) that increase assurance without breaking the open/self-hosted trust model. +4. **Recovery as a protocol** — explicit, auditable ceremonies that keep *recovering access* strictly separate from *replacing or rotating a familiar's identity*. + +Non-goals: implementing WebAuthn servers, Apple App Attest, or Android attestation verification in this repository; hosting an account service; changing the pairing v1/v2 QR enrollment path; widening any v1 grant. + +## 2. Current implementation baseline + +Claims about the present system cite the code: + +| Fact | Path | +| --- | --- | +| Authority layer (pairing, auth, registry, gateway, audit) | `crates/coven-cli/src/mobile_memory/` (`pairing.rs`, `auth.rs`, `registry.rs`, `gateway.rs`, `audit.rs`) | +| Grant model: `DeviceScope` (12 capabilities), `AssuranceLevel` (`possession`, `recent_user_verification`, `fresh_user_verification`, `fresh_biometric`, `step_up`), `DeviceGrant` with `subject_key_id`, audience, restrictions, `revocation_epoch` | `crates/coven-cli/src/mobile_memory/grant.rs` | +| Exact-action authorization: `DeviceActionIntent` canonicalizes scope, operation, target, effect digest, nonce, and a ≤300 s window over `COVEN-ACTION/1` | `crates/coven-cli/src/mobile_memory/grant.rs` (`canonical_bytes`) | +| Device records: `DeviceRecord` + per-device `DeviceGrant`, atomic replacement, bounded at 128 records, legacy v1 migration | `crates/coven-cli/src/mobile_memory/registry.rs` | +| Request authentication: `COVEN-MEMORY/1` canonical string (method, path+query, timestamp, nonce, body digest), P-256 ECDSA, ±300 s window, replay cache (10 000 entries), 120 requests/window rate limit | `crates/coven-cli/src/mobile_memory/auth.rs` | +| Pairing: single-use nonce, `COVEN-PAIR/2` transcript domain, transcript-derived six-word phrase, idempotent completion | `crates/coven-cli/src/mobile_memory/pairing.rs` | +| Audit: append-only JSONL `audit.jsonl` with `MobileAuditEvent` (`PairingCreated`, `PairingCompleted`, `PairingRejected`, `DeviceRevoked`, `AuthenticationRejected`, `RateLimited`, …) | `crates/coven-cli/src/mobile_memory/audit.rs` | +| CLI surface: `coven memory mobile enable|disable|status|pair|devices [revoke ]` | `crates/coven-cli/src/main.rs`, `crates/coven-cli/src/mobile_memory/mod.rs` | +| Gateway routes under `/api/v1/mobile/*`, private-network HTTPS-only bind | `crates/coven-cli/src/mobile_memory/gateway.rs`, `config.rs` | +| Diagnostic JSON schemas for offer/grant/enrollment/transaction/revocation objects | `spec/device-pairing/v1/*.schema.json` | +| Capability and assurance vocabularies | `spec/device-pairing/v1/capabilities.json` | +| Domain-separation label registry | `spec/device-pairing/v1/domain-separation.md` | +| Rendezvous relay (bounded, opaque) | `crates/coven-relay/src/ws.rs` | +| Familiar identity (manifest, resolver, effective familiar) | `crates/coven-cli/src/familiar_identity.rs`, `docs/familiars/identity.md` | + +What does **not** exist yet, and what this plan adds: trusted-device introduction objects, a passkey/account factor contract, recovery ceremonies and events, identity-rotation semantics, and attestation assurance attributes. The accepted architecture already sketches these in [`mobile-device-trust.md`](mobile-device-trust.md) §"Passkeys, trusted-device introduction, and recovery", §"Optional attestation", and migration Stage D; this document turns those paragraphs into normative objects, state machines, policies, and examples. + +## 3. Protocol objects + +New objects are added to the pairing protocol family at object version 1. Their canonical encoding is deterministic CBOR in a COSE envelope, exactly like the v1 objects (`spec/device-pairing/v1/conformance-manifest.json`); the JSON below is the diagnostic form, in the style of `spec/device-pairing/v1/device-grant.schema.json`. The implementation PR lands these schemas as files under `spec/device-pairing/v2/` and registers the new objects in a v2 conformance manifest. + +New domain-separation labels (to be appended to `spec/device-pairing/v1/domain-separation.md`; code-level short forms follow the `COVEN-PAIR/2` convention in `pairing.rs`): + +```text +OpenCoven/IntroductionRequest/v1 (short form: COVEN-INTRO-REQ/1) +OpenCoven/IntroductionApproval/v1 (short form: COVEN-INTRO-APPR/1) +OpenCoven/IntroductionTranscript/v1 (short form: COVEN-INTRO-TX/1) +OpenCoven/RecoveryPolicy/v1 (short form: COVEN-RECOVERY-POLICY/1) +OpenCoven/RecoveryEvent/v1 (short form: COVEN-RECOVERY-EVENT/1) +OpenCoven/AttestationClaim/v1 (short form: COVEN-ATTEST-CLAIM/1) +``` + +Shared `$defs` reused from the v1 schemas: `keyReference` (algorithm `Ed25519 | P-256`, `keyId`, `publicKey`), `identityReference` (type `owner | installation | device | trust-domain`), integer timestamps. + +### 3.1 IntroductionRequest + +Created by the **new endpoint**, delivered over the authenticated E2EE channel (#787) or shown as a follow-on request to an existing trusted device. It commits to everything the approval will be about. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/device-pairing/v2/introduction-request.schema.json", + "title": "OpenCoven IntroductionRequest v1 diagnostic JSON representation", + "description": "Request from a new endpoint asking an existing trusted device or the owner to introduce it into one trust domain. Canonical signed form is deterministic CBOR in COSE_Sign1.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", "trustDomain", "installationFingerprint", "endpointKey", + "pairwiseDeviceId", "requestedScopes", "requestedAssurance", + "deviceContext", "nonce", "expiresAt", "signature" + ], + "properties": { + "version": { "const": 1 }, + "trustDomain": { "type": "string", "minLength": 8, "maxLength": 256 }, + "installationFingerprint": { + "description": "SHA-256 over the target installation's canonical host key, base64url; binds the request to one installation.", + "type": "string", "minLength": 43, "maxLength": 43, + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "endpointKey": { "$ref": "#/$defs/keyReference" }, + "pairwiseDeviceId": { + "description": "Domain-separated identifier derived per (endpoint key, trust domain); never a global device ID.", + "type": "string", "minLength": 8, "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "requestedScopes": { + "type": "array", "uniqueItems": true, "minItems": 1, + "items": { "enum": [ + "sessions.metadata.read", "conversations.read", "messages.send", + "tools.request", "tools.approve", "secrets.read", + "memory.familiar.read", "memory.familiar.write", + "identity.admin", "devices.enroll", "devices.revoke", + "identity.export", "memory.export" + ] } + }, + "requestedAssurance": { + "enum": ["possession", "recent_user_verification", "fresh_user_verification", "fresh_biometric", "step_up"] + }, + "deviceContext": { + "type": "object", + "additionalProperties": false, + "required": ["platform", "humanReadableName"], + "properties": { + "platform": { "enum": ["ios", "android", "macos", "linux", "windows", "other"] }, + "humanReadableName": { "type": "string", "minLength": 1, "maxLength": 80 }, + "appVariant": { + "description": "Free-text build context such as 'official build' or 'self-built'; never an authority input by itself.", + "type": "string", "maxLength": 80 + } + } + }, + "nonce": { + "description": "Fresh 32-byte random value, base64url; single-use.", + "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "expiresAt": { "type": "integer", "minimum": 0 }, + "signature": { + "description": "endpointKey signature over OpenCoven/IntroductionRequest/v1 canonical bytes.", + "type": "string", "pattern": "^[A-Za-z0-9_-]+$" + } + }, + "$defs": { + "keyReference": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "keyId", "publicKey"], + "properties": { + "algorithm": { "enum": ["Ed25519", "P-256"] }, + "keyId": { "type": "string", "minLength": 8, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$" }, + "publicKey": { "type": "string", "minLength": 43, "maxLength": 128, "pattern": "^[A-Za-z0-9_-]+$" } + } + } + } +} +``` + +Privacy constraints (extend `spec/device-pairing/v1/privacy.md` rule 8): `deviceContext` must not carry hardware serials, advertising IDs, account-provider identifiers, phone numbers, or biometric metadata. `humanReadableName` is display-only and capped at 80 characters, matching `MAX_DEVICE_NAME_CHARS` in `registry.rs`. + +### 3.2 IntroductionApproval + +Created by each **approving device** (or the owner root credential) after a fresh step-up verification. Each approval is independently verifiable; the installation authority counts them against policy. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/device-pairing/v2/introduction-approval.schema.json", + "title": "OpenCoven IntroductionApproval v1 diagnostic JSON representation", + "description": "One approval of an IntroductionRequest, signed by an existing trusted device key or the owner root credential after fresh local user verification.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", "introductionTranscriptHash", "approver", "approverRole", + "assurance", "assuranceNonce", "issuedAt", "expiresAt", "signature" + ], + "properties": { + "version": { "const": 1 }, + "introductionTranscriptHash": { + "description": "SHA-256 over OpenCoven/IntroductionTranscript/v1 canonical bytes; commits to the full request and grant template.", + "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "approver": { "$ref": "#/$defs/keyReference" }, + "approverRole": { "enum": ["trusted-device", "owner-root", "recovery-provider"] }, + "assurance": { + "description": "Locally verified step-up level at signing time; MUST be fresh_user_verification or fresh_biometric for trusted-device approvers.", + "enum": ["fresh_user_verification", "fresh_biometric", "step_up"] + }, + "assuranceNonce": { + "description": "32-byte value displayed/echoed by the step-up UI and bound into the signature; prevents evidence replay.", + "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "attestation": { + "description": "Optional assurance attributes held by the approver, minimally included when policy requires them.", + "type": "array", "uniqueItems": true, + "items": { "enum": ["verified_official_app", "verified_hardware_key"] } + }, + "issuedAt": { "type": "integer", "minimum": 0 }, + "expiresAt": { "type": "integer", "minimum": 0 }, + "signature": { + "description": "approver signature over OpenCoven/IntroductionApproval/v1 canonical bytes.", + "type": "string", "pattern": "^[A-Za-z0-9_-]+$" + } + }, + "$defs": { + "keyReference": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "keyId", "publicKey"], + "properties": { + "algorithm": { "enum": ["Ed25519", "P-256"] }, + "keyId": { "type": "string", "minLength": 8, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$" }, + "publicKey": { "type": "string", "minLength": 43, "maxLength": 128, "pattern": "^[A-Za-z0-9_-]+$" } + } + } + } +} +``` + +The **introduction transcript** is canonical and covers at minimum: protocol version, `trustDomain`, `installationFingerprint`, the new endpoint public key and `pairwiseDeviceId`, the full `requestedScopes` set, `requestedAssurance`, the `deviceContext` digest, `nonce`, `expiresAt`, the *grant template* (audience, restrictions, `minimum_assurance`, planned `expires_at` policy), and the identity-reference of the expected issuer. This is the same transcript principle as `docs/design/mobile-device-trust.md` §"Transcript binding" and the `COVEN-PAIR/2` transcript in `pairing.rs` (`PairingTranscript::hash`). + +### 3.3 RecoveryPolicy + +Owner-controlled policy document, stored and interpreted by the authority layer (never by clients or the account service). + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/device-pairing/v2/recovery-policy.schema.json", + "title": "OpenCoven RecoveryPolicy v1 diagnostic JSON representation", + "type": "object", + "additionalProperties": false, + "required": ["version", "trustDomain", "introductionPolicy", "recoveryFactors", "attestationPolicy", "updatedAt"], + "properties": { + "version": { "const": 1 }, + "trustDomain": { "type": "string", "minLength": 8, "maxLength": 256 }, + "introductionPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["standardMinApprovals", "rootMinApprovals", "requireDistinctApprovers"], + "properties": { + "standardMinApprovals": { "type": "integer", "minimum": 1, "maximum": 9 }, + "rootMinApprovals": { "type": "integer", "minimum": 1, "maximum": 9 }, + "requireDistinctApprovers": { "type": "boolean" } + } + }, + "recoveryFactors": { + "description": "Factor combination that is sufficient to restore owner access. At least two distinct factor kinds are REQUIRED by this plan.", + "type": "array", "minItems": 2, "uniqueItems": true, + "items": { "enum": [ + "passkey_account", "recovery_key", "trusted_device", + "owner_root_credential", "attested_device", "n_of_m_devices" + ] } + }, + "recoveryThreshold": { + "type": "object", + "additionalProperties": false, + "required": ["of", "minimum"], + "properties": { + "of": { "type": "integer", "minimum": 1, "maximum": 9 }, + "minimum": { "type": "integer", "minimum": 1, "maximum": 9 } + } + }, + "attestationPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["default", "requireFor"], + "properties": { + "default": { "enum": ["ignore", "prefer"] }, + "requireFor": { + "type": "array", "uniqueItems": true, + "items": { "enum": ["secrets_read", "identity_admin", "devices_enroll", "devices_revoke", "identity_export", "memory_export"] } + } + } + }, + "updatedAt": { "type": "integer", "minimum": 0 } + } +} +``` + +### 3.4 RecoveryEvent + +Append-only audit record, same storage discipline as `audit.jsonl` in `audit.rs` (private directory, atomic append, no secret material). + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/device-pairing/v2/recovery-event.schema.json", + "title": "OpenCoven RecoveryEvent v1 diagnostic JSON representation", + "description": "Explicit, auditable record of a recovery or rotation ceremony. Never contains private keys, passkey identifiers, or account-provider identifiers.", + "type": "object", + "additionalProperties": false, + "required": ["version", "eventId", "kind", "subject", "actors", "epoch", "occurredAt"], + "properties": { + "version": { "const": 1 }, + "eventId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{22}$" }, + "kind": { + "enum": [ + "recovery_started", "recovery_denied", "access_restored", + "device_key_rotated", "identity_rotated", "root_rotated", + "familiar_identity_adopted", "recovery_policy_changed" + ] + }, + "subject": { "$ref": "#/$defs/identityReference" }, + "actors": { + "description": "Abstract factor kinds only (see privacy.md rule 8): no credential IDs, no key material.", + "type": "array", "uniqueItems": true, "minItems": 1, + "items": { "enum": ["passkey_account", "recovery_key", "trusted_device", "owner_root_credential", "attested_device", "n_of_m_devices"] } + }, + "epoch": { + "description": "revocation_epoch value after this event; rotations increment it.", + "type": "integer", "minimum": 0 + }, + "rotatesIdentity": { "type": "boolean" }, + "outOfBandContext": { + "description": "Human-readable ceremony description safe for audit; no secrets.", + "type": "string", "maxLength": 200 + }, + "occurredAt": { "type": "integer", "minimum": 0 } + }, + "$defs": { + "identityReference": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { "enum": ["owner", "installation", "device", "trust-domain", "familiar"] }, + "id": { "type": "string", "minLength": 8, "maxLength": 256, "pattern": "^[A-Za-z0-9._:-]+$" } + } + } + } +} +``` + +### 3.5 AttestationClaim + +An **assurance attribute** bound to a device key inside one trust domain. It is evidence about the app/key environment, never an identity, and never sufficient by itself for anything. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/device-pairing/v2/attestation-claim.schema.json", + "title": "OpenCoven AttestationClaim v1 diagnostic JSON representation", + "type": "object", + "additionalProperties": false, + "required": ["version", "attribute", "evidence", "subject", "audience", "nonce", "issuedAt", "expiresAt", "verifierSignature"], + "properties": { + "version": { "const": 1 }, + "attribute": { "enum": ["verified_official_app", "verified_hardware_key", "unattested_device"] }, + "evidence": { + "enum": ["apple_app_attest", "android_key_attestation", "android_play_integrity", "self_declared", "none"] + }, + "subject": { "$ref": "#/$defs/keyReference" }, + "audience": { "$ref": "#/$defs/identityReference" }, + "nonce": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, + "issuedAt": { "type": "integer", "minimum": 0 }, + "expiresAt": { "type": "integer", "minimum": 0 }, + "verifierSignature": { + "description": "Signature of the attestation verifier over OpenCoven/AttestationClaim/v1 canonical bytes.", + "type": "string", "pattern": "^[A-Za-z0-9_-]+$" + } + }, + "$defs": { + "keyReference": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "keyId", "publicKey"], + "properties": { + "algorithm": { "enum": ["Ed25519", "P-256"] }, + "keyId": { "type": "string", "minLength": 8, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$" }, + "publicKey": { "type": "string", "minLength": 43, "maxLength": 128, "pattern": "^[A-Za-z0-9_-]+$" } + } + }, + "identityReference": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { "enum": ["trust-domain", "installation"] }, + "id": { "type": "string", "minLength": 8, "maxLength": 256, "pattern": "^[A-Za-z0-9._:-]+$" } + } + } + } +} +``` + +### 3.6 TypeScript reference types + +Reference shapes for the TypeScript integration packages (`packages/`). They are documentation of the diagnostic JSON, not a second authority; the Rust authority layer remains the only decision point (see `docs/design/mobile-device-trust.md` §"Authority boundary"). + +```typescript +export type Capability = + | 'sessions.metadata.read' | 'conversations.read' | 'messages.send' + | 'tools.request' | 'tools.approve' | 'secrets.read' + | 'memory.familiar.read' | 'memory.familiar.write' + | 'identity.admin' | 'devices.enroll' | 'devices.revoke' + | 'identity.export' | 'memory.export'; + +export type Assurance = + | 'possession' | 'recent_user_verification' | 'fresh_user_verification' + | 'fresh_biometric' | 'step_up'; + +export type AttestationAttribute = + | 'verified_official_app' | 'verified_hardware_key' | 'unattested_device'; + +export interface KeyReference { + algorithm: 'Ed25519' | 'P-256'; + keyId: string; + publicKey: string; // unpadded base64url +} + +export interface IntroductionRequest { + version: 1; + trustDomain: string; + installationFingerprint: string; // base64url SHA-256 of host key + endpointKey: KeyReference; + pairwiseDeviceId: string; + requestedScopes: Capability[]; + requestedAssurance: Assurance; + deviceContext: { + platform: 'ios' | 'android' | 'macos' | 'linux' | 'windows' | 'other'; + humanReadableName: string; + appVariant?: string; + }; + nonce: string; + expiresAt: number; // unix seconds + signature: string; +} + +export interface IntroductionApproval { + version: 1; + introductionTranscriptHash: string; + approver: KeyReference; + approverRole: 'trusted-device' | 'owner-root' | 'recovery-provider'; + assurance: 'fresh_user_verification' | 'fresh_biometric' | 'step_up'; + assuranceNonce: string; + attestation?: Exclude[]; + issuedAt: number; + expiresAt: number; + signature: string; +} + +export type RecoveryFactor = + | 'passkey_account' | 'recovery_key' | 'trusted_device' + | 'owner_root_credential' | 'attested_device' | 'n_of_m_devices'; + +export interface RecoveryPolicy { + version: 1; + trustDomain: string; + introductionPolicy: { + standardMinApprovals: number; + rootMinApprovals: number; + requireDistinctApprovers: boolean; + }; + recoveryFactors: RecoveryFactor[]; + recoveryThreshold?: { of: number; minimum: number }; + attestationPolicy: { default: 'ignore' | 'prefer'; requireFor: Capability[] }; + updatedAt: number; +} + +export type RecoveryEventKind = + | 'recovery_started' | 'recovery_denied' | 'access_restored' + | 'device_key_rotated' | 'identity_rotated' | 'root_rotated' + | 'familiar_identity_adopted' | 'recovery_policy_changed'; + +export interface RecoveryEvent { + version: 1; + eventId: string; + kind: RecoveryEventKind; + subject: { type: 'owner' | 'installation' | 'device' | 'trust-domain' | 'familiar'; id: string }; + actors: RecoveryFactor[]; + epoch: number; + rotatesIdentity?: boolean; + outOfBandContext?: string; + occurredAt: number; +} + +export interface AttestationClaim { + version: 1; + attribute: AttestationAttribute; + evidence: 'apple_app_attest' | 'android_key_attestation' | 'android_play_integrity' | 'self_declared' | 'none'; + subject: KeyReference; + audience: { type: 'trust-domain' | 'installation'; id: string }; + nonce: string; + issuedAt: number; + expiresAt: number; + verifierSignature: string; +} +``` + +## 4. Trusted-device introduction + +### 4.1 Flow + +```text +New Psyche/TUI node Existing trusted phone + │ │ + │ 1. IntroductionRequest │ + │─────────── over authenticated E2EE ───────▶│ + │ │ 2. render material fields + │ │ fresh step-up (biometric / user verification) + │ │ IntroductionApproval signed by device key + │ 3. approvals collected │ + │◀───────────────────────────────────────────┘ + ▼ +Installation authority (Rust) + │ 4. verify signatures, count approvals vs RecoveryPolicy, + │ re-check transcript, bind grant to endpointKey + ▼ +scoped grant → new installation (DeviceGrant, registry.rs) +``` + +Delivery reuses the #787 channels: the authenticated relayed session (`crates/coven-relay/`) or the direct mobile gateway (`gateway.rs`). The push notification, when used, is a wake-up that names an opaque pending request — it carries no authorization (privacy rule 6, `spec/device-pairing/v1/privacy.md`). + +### 4.2 Transcript binding (issue checkbox 1) + +The approval MUST bind, in the signed `introductionTranscriptHash`: + +- the new endpoint public key (`endpointKey`) and its `pairwiseDeviceId`; +- the exact `requestedScopes` set and `requestedAssurance`; +- the single-use `nonce` and `expiresAt`; +- the human-readable `deviceContext` (digest of the rendered text the approver saw); +- the target `installationFingerprint` and trust domain; +- the grant template the authority will issue. + +Any change to any of these changes the transcript hash and invalidates every collected approval. This is the same fail-closed principle as capability substitution defense in the threat model, and it reuses the canonical-bytes discipline of `DeviceActionIntent::canonical_bytes` (`grant.rs`). + +### 4.3 Fresh step-up authentication (issue checkbox 2) + +An introduction approval is a step-up operation by definition. The approving device MUST verify the local user immediately before signing: + +- `assurance: fresh_biometric` where the platform enforces biometric policy (iOS LocalAuthentication / Android BiometricPrompt strong biometrics), otherwise `fresh_user_verification`; +- the step-up result gates use of the device private key exactly as in `docs/design/mobile-device-trust.md` §"Biometrics and step-up authorization" — the biometric itself never enters the protocol; +- `assuranceNonce` binds the approval to this specific step-up event, so captured approvals cannot be replayed for a different request; +- passcode fallback is representable only as `fresh_user_verification`, never as `fresh_biometric` (threat model: "Biometric exfiltration or false representation"). + +### 4.4 Introduction state machine + +```text +drafted + │ endpoint signs request + ▼ +requested ── expiresAt / cancel / malformed ──▶ terminal (erased) + │ approvals collected (one or more, per policy) + ▼ +approved ── authority verifies + counts ──▶ denied → terminal (RecoveryEvent: recovery_denied) + │ transcript re-verified at authority + ▼ +enrolling + │ grant written (registry.register_with_grant) + ▼ +completed ── later revoke/expire/rotate ──▶ revoked-or-expired (existing registry semantics) +``` + +Rules: + +- Nonces are single-use and bounded; expired requests are pruned like pairing invitations (`PairingManager::prune_expired`, `pairing.rs`). +- Approval counting happens only in the authority layer; a relay, the account service, or the requesting endpoint can never count its own approvals. +- The completed introduction is auditable: the authority appends a `RecoveryEvent` with `kind: access_restored` (for recovery paths) or extends the device lifecycle events in `audit.rs` with the introduction outcome. + +### 4.5 One device or N-of-M? (issue checkbox 3) + +**Recommendation:** one fresh-step-up trusted device is sufficient by default; root-level policy can require N-of-M. + +| Enrollment class | Default policy | Rationale | +| --- | --- | --- | +| Standard endpoint (conversation/message scopes) | `standardMinApprovals: 1` | Matches the QR bootstrap trust level; friction proportional to risk. | +| Elevated endpoint (`tools.approve`, `secrets_read`) | `standardMinApprovals: 1` + `attestationPolicy.requireFor` or narrower grant | Scope narrowing substitutes for ceremony overhead. | +| Root-level endpoint (`identity.admin`, `devices.enroll`, `devices.revoke`, `identity.export`, `memory.export`) | `rootMinApprovals: 2` with `requireDistinctApprovers: true` | A single compromised trusted device must not be able to mint root-level authority silently. | + +Alternatives considered: (a) always N-of-M — rejected as the default because a sole owner with one phone could never bootstrap; (b) always 1 — rejected for root-level classes because it concentrates trust in one device; (c) time-delayed approval (24 h cooling) — kept as an optional owner knob, not a default. The policy knob lives in `RecoveryPolicy.introductionPolicy`; the owner decides; the authority enforces. + +### 4.6 Compromised relay / account service (issue checkbox 4) + +The property to hold: **a compromised relay or account service alone cannot enroll an endpoint.** + +- Enrollment authority is derived only from signatures over the introduction transcript made by trusted-device keys or the owner root credential. Neither the relay (`crates/coven-relay/`) nor any account service holds those keys or a role in the signature chain. +- The account service can, at most, deliver requests and echo opaque state. Its artifacts are consumed only as *assurance attributes* (`AttestationClaim`), which never mint authority (threat-model invariant 8: "A cloud account, passkey, push provider, or attestation provider alone cannot mint root authority"). +- An offline/self-hosted Coven completes introductions with no account service at all: the account path is optional and additive. +- The authority re-derives and re-verifies the transcript server-side before issuing the grant, so a malicious intermediary cannot present an approval for a different request (transcript substitution fails the hash check). + +## 5. Passkeys + +### 5.1 Where passkeys are used + +| Use | Mechanism | Authority effect | +| --- | --- | --- | +| Optional OpenCoven account sign-in | WebAuthn/passkey ceremony against the (optional) account provider | Account-session only; no Coven authority | +| Account/recovery authentication | Passkey ceremony as one `passkey_account` factor | Counts toward `RecoveryPolicy.recoveryFactors` | +| Remote enrollment authorization | Account-authenticated session *initiates* an introduction request delivery | Still requires trusted-device/owner approvals (§4.6) | +| Approving a new installation from an already trusted device | Passkey as an additional factor on the approving device, never a substitute for its device key | Assurance attribute at most | +| Cross-platform browser/native login | Standard WebAuthn; platform-neutral | Account-session only | + +### 5.2 Hard prohibitions (issue checkboxes) + +A passkey — in particular a synced passkey — MUST NOT be treated as: + +1. **the sole root key of a familiar** — familiar identity keys are managed by the authority layer and the identity resolver (`familiar_identity.rs`, `docs/familiars/identity.md`); a synced passkey exists in a platform sync fabric outside OpenCoven's control; +2. **proof of one particular physical device** — a synced passkey may be available on every device in a platform account; it proves account possession, not device possession. Device identity remains the pairwise, non-exportable device key (`grant.rs` `subject_key_id`); +3. **a globally reused familiar/device identifier** — passkey credential IDs are scoped to the account RP and MUST NOT appear in Coven protocol surfaces (privacy rule 8); +4. **the only recovery mechanism** — `RecoveryPolicy.recoveryFactors` must always offer at least one account-independent path (recovery key, owner root credential, or trusted-device quorum). + +Mechanistically: a successful passkey ceremony yields an account-layer session that may *request* ceremonies and may contribute the `passkey_account` factor to recovery counting. It never signs `DeviceGrant`s, never appears in `DeviceGrant.subject_key_id`, and never substitutes for `fresh_user_verification`/`fresh_biometric` in an `IntroductionApproval`. + +## 6. Recovery + +### 6.1 Recovery is a first-class protocol + +Recovery ceremonies are explicit objects and events (`RecoveryPolicy`, `RecoveryEvent`), not a password-reset fallback. Evaluated combinations: + +| Combination | Use | Notes | +| --- | --- | --- | +| passkey/account + trusted device | Owner regains account, then approves re-enrollment from the trusted phone | Common "new laptop" path | +| recovery key/seed | Offline, account-independent restoration | Generated at first enrollment, displayed once, stored by the owner outside OpenCoven; the seed never transits the account service | +| another trusted device | Surviving-device approval of a new endpoint | §4 | +| N-of-M trusted-device approval | Owner policy for high-assurance households/teams | `recoveryThreshold` | +| owner root credential | Last-resort local ceremony on the installation itself | Works with all network paths down | + +Minimum viable default (recommended): **recovery key + passkey account**, with the trusted-device path always available while any enrolled device survives. All combinations are owner-selectable via `RecoveryPolicy`. + +### 6.2 Recovering access ≠ rotating identity (issue requirement) + +Two distinct ceremonies with distinct event kinds: + +```text +Recovery (restore access) Rotation (replace identity) +recovery_started identity_rotated proposed + │ factors collected per RecoveryPolicy │ quorum per introductionPolicy.rootMinApprovals + ▼ ▼ +access_restored rotation quorum met + │ same familiar/installation identity │ revocation_epoch += 1 (DeviceGrant.revocation_epoch) + │ new DeviceGrant(s) for the new endpoint │ old keys revoked; new keys enrolled + │ familiar identity untouched │ familiar identity key replaced deliberately + ▼ ▼ +RecoveryEvent(kind: access_restored, RecoveryEvent(kind: identity_rotated, + rotatesIdentity: false) rotatesIdentity: true) +``` + +Invariants: + +- Recovery NEVER rotates, deletes, or rewrites familiar identity or memory (threat-model invariant: "Revoking a device does not rotate or destroy familiar identity"). +- Rotation is always explicit: it requires the root-level threshold (§4.5), produces `RecoveryEvent` records with `rotatesIdentity: true`, bumps `revocation_epoch` (the same epoch semantics `DeviceGrant` and `registry.rs` already use to invalidate pre-rotation state), and is refused while any recovery ceremony is mid-flight. +- Every step of both ceremonies appends `RecoveryEvent` records to the same private append-only audit stream as `audit.rs` — recovery events and key rotations are explicit and auditable. + +### 6.3 Golden example — recovery event after a lost laptop + +Example literals in this section are intentionally synthetic placeholders (repeated low-entropy tokens), not real key material. + +```json +{ + "version": 1, + "eventId": "evt-evt-evt-evt-evt-ev", + "kind": "access_restored", + "subject": { "type": "device", "id": "trust-alpha:pairwise-7f3a91" }, + "actors": ["passkey_account", "trusted_device"], + "epoch": 4, + "rotatesIdentity": false, + "outOfBandContext": "Lost laptop re-enrolled as replacement endpoint after passkey account auth and phone approval", + "occurredAt": 1790000000 +} +``` + +## 7. Optional attestation + +### 7.1 Attribute registry + +Canonical snake_case values match `restrictions.attestation` in `spec/device-pairing/v1/device-grant.schema.json` (display labels from the issue: *verified-official-app*, *verified-hardware-key*, *unattested-device*): + +| Attribute | Meaning | Typical evidence | +| --- | --- | --- | +| `unattested_device` | Default for every device; self-built clients, dev builds, self-hosted runtimes | `none` or `self_declared` | +| `verified_official_app` | App binary provenance verified (e.g. Apple App Attest; Android Play integrity signals) | `apple_app_attest`, `android_play_integrity` | +| `verified_hardware_key` | Key is hardware-protected (e.g. Secure Enclave; Android hardware-backed Keystore attestation) | `apple_app_attest` key assurance, `android_key_attestation` | + +Mapping notes: Apple App Attest and Android key attestation are evaluated by an optional verifier component; OpenCoven protocol surfaces see only the resulting `AttestationClaim` — opaque receipts, no attestation payloads, per privacy rule 7 ("attestation values are minimized to policy-relevant assurance claims"). + +### 7.2 Policy integration and the open trust model + +- Absence of attestation is **not** a protocol failure: `unattested_device` is a full participant subject to owner policy (threat model: "Attestation lock-in" controls). +- `attestationPolicy.default` is `prefer` or `ignore`; `requireFor` may name only high-risk capabilities (`secrets_read`, `identity_admin`, device enrollment/revocation, exports) and only the owner can set it. +- Attestation never replaces proof of possession, owner delegation, or local user verification; it can only *add* assurance to an operation that already passed those checks. +- Attestation claims are audience-bound to one trust domain and expire; they cannot correlate a device across Covens (privacy rules 1 and 8). +- Self-hosted deployments can run their own verifier or none at all; nothing in the protocol requires a proprietary service. + +### 7.3 Golden example — policy-gated secrets approval + +```json +{ + "version": 1, + "attribute": "verified_hardware_key", + "evidence": "android_key_attestation", + "subject": { + "algorithm": "P-256", + "keyId": "trust-alpha:device-key-placeholder", + "publicKey": "pk-pk-pk-pk-pk-pk-pk-pk-pk-pk-pk-pk-pk-pk-p" + }, + "audience": { "type": "trust-domain", "id": "trust-alpha" }, + "nonce": "nonce-nonce-nonce-nonce-nonce-nonce-nonce-n", + "issuedAt": 1790000000, + "expiresAt": 1790086400, + "verifierSignature": "sig-sig-sig-sig-sig-sig-sig-sig-sig-sig-sig-s" +} +``` + +With `attestationPolicy.requireFor: ["secrets_read"]`, a `tools.approve` transaction on `secrets_read` (see `DeviceActionIntent`, `grant.rs`) succeeds only when the approving device key carries a valid `verified_hardware_key` or `verified_official_app` claim. The same transaction from an `unattested_device` key fails with a policy error that names the missing attribute — never a generic denial. + +## 8. Policy examples (issue table, mapped) + +| Scenario | Required policy | +| --- | --- | +| Normal conversation | Valid enrolled device: device key + live `DeviceGrant` (`possession`) — `auth.rs` request verification path | +| Tool approval | Enrolled device + fresh local user verification — `DeviceActionIntent` with `require_fresh_user_verification_for` restriction (`grant.rs`) | +| Secrets/root administration | Hardware-backed or attested device and/or second factor when `attestationPolicy.requireFor` / `rootMinApprovals` say so (§4.5, §7.2) | +| New-device enrollment | Fresh step-up on an existing trusted device; threshold approval per `introductionPolicy`; optionally attested approvers (§4) | + +## 9. Threat-model deltas + +Extends `docs/security/mobile-device-pairing-threat-model.md` without weakening any existing control: + +- **Compromised trusted device**: can introduce one scoped endpoint within `expiresAt`; root-level classes require `rootMinApprovals ≥ 2` distinct approvers; the epoch bump and audit trail bound and expose the damage; owner revokes via the existing `registry.revoke` path. +- **Passkey/account provider compromise**: cannot enroll endpoints (§4.6); cannot read or forge E2EE sessions; can at most request attention. +- **Attestation forgery**: verifier compromise yields only assurance attributes, which cannot mint authority; owner policy that never sets `requireFor` is unaffected. +- **Recovery-ceremony phishing**: `assuranceNonce` and transcript hashing make collected approvals useless for any other request; recovery events are user-visible in `coven memory mobile status` output (which already surfaces device/grant state, `registry.list_status`). + +## 10. Acceptance criteria mapping + +| Issue acceptance criterion | Where satisfied | +| --- | --- | +| Recover/enroll remotely without exposing familiar/root private material to OpenCoven infrastructure | §4.1 (transcript-bound approvals), §5.1, §6.1 (recovery key stays owner-held); grants issued locally by the authority | +| Synced passkeys do not masquerade as physical-device identity | §5.2 prohibitions; `subject_key_id` remains the pairwise device key | +| Attestation increases assurance without breaking the open/self-hosted model | §7.2; `unattested_device` full participation; no mandatory proprietary dependency | +| A compromised account service alone cannot mint arbitrary device authority | §4.6; threat-model invariant 8 | +| Recovery events and identity/key rotations are explicit and auditable | §3.4 `RecoveryEvent`, §6.2 ceremonies, §6.3 example; same audit stream as `audit.rs` | +| Owner can choose stricter policies without imposing proprietary platforms on every Coven | §3.3 `RecoveryPolicy`, §4.5 defaults, §7.2 policy knobs | + +## 11. Implementation staging + +Aligned with delivery-plan PR 7; each sub-stage is independently mergeable and preserves v1/v2 behavior: + +1. **7a — objects and policy engine (Rust, authority layer):** new module (suggested: `crates/coven-cli/src/mobile_memory/recovery.rs`) with the five objects, canonical encoding, domain labels, `RecoveryPolicy` storage and evaluation; registry extension for introduction state; JSON schemas under `spec/device-pairing/v2/` plus a v2 conformance manifest and test-vector files; adversarial tests (substitution, replay, threshold bypass, expired approvals). +2. **7b — introduction flow:** CLI (`coven memory mobile introduce`), approval UI surface on the trusted device, delivery over #787 transports, gateway/internal routes mirroring the existing `/api/v1/internal/mobile/pairings` pattern (`gateway.rs`), audit integration. +3. **7c — recovery ceremonies and events:** `RecoveryPolicy` ceremony runner, `RecoveryEvent` append-only log, epoch-bump rotation path, `coven memory mobile recovery` command group, export/redaction rules. +4. **7d — optional attestation adapter contract:** verifier adapter interface (no proprietary SDK in core), claim cache with expiry, policy gating in the authority, `attestationPolicy` enforcement tests for both `prefer` and `requireFor` modes and for the no-verifier/self-hosted configuration. + +Merge gates: the repository gates (`cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace --locked`, `python scripts/check-secrets.py`, `python3 scripts/check-coven-privacy.py --staged`) plus the delivery-plan merge policy: positive, negative, replay, downgrade, and malformed-input tests appropriate to the layer; migration/compatibility notes for stored registry state. + +## 12. Decisions and alternatives considered + +| Decision | Choice | Alternatives rejected/deferred | +| --- | --- | --- | +| Who may approve an introduction | Trusted-device keys and the owner root credential, after fresh step-up | Account service as approver — rejected (invariant 8); relay as approver — rejected | +| 1 vs N-of-M | 1 by default, N-of-M for root classes via owner policy (§4.5) | Always-N — locks out single-device owners; always-1 — concentrates root trust | +| Passkey → familiar root | Never; account-layer factor only (§5.2) | Synced-passkey-as-root — rejected by issue and architecture | +| Recovery default factors | recovery key + passkey account + surviving trusted device (§6.1) | Hosted Shamir/social recovery — deferred (privacy tradeoff, needs its own threat model); security questions — rejected | +| WebAuthn prf-derived owner keys | Deferred | Ties the owner root to account-provider availability; revisit only with an explicit owner opt-in | +| Attestation requirement | Optional, policy-gated, per-operation (§7.2) | Global mandatory attestation — rejected (breaks self-built/self-hosted clients) | +| Attestation value space | Reuse the v1 grant restriction enum (§7.1) | New vocabulary — rejected (fragmentation, migration cost) | +| Where the account factor lives | Optional external provider; protocol sees only abstract factor kinds (§5) | First-party required account — rejected; protocol-embedded account — out of scope for this repo | + +## 13. Verification plan + +Implementation PRs must add, at minimum: + +- deterministic CBOR/COSE vectors for each new object (canonical-encoding suite, `conformance-manifest.json` suites); +- negative tests: transcript substitution, scope substitution, expired/single-use nonce replay, threshold bypass, approval-for-another-request, unknown attribute/evidence values (fail closed), attestation claim expiry; +- integration tests with a malicious relay/account shim: no enrollment without valid approvals; account service compromise reduces to a no-op on the authority path; +- privacy tests: no passkey credential IDs, attestation receipts, hardware IDs, or biometric metadata in any protocol object, audit record, or log line (extends `scripts/check-coven-privacy.py` coverage); +- revocation/rotation tests: epoch bump invalidates pre-rotation grants and resumption material while leaving familiar identity untouched. diff --git a/docs/development/cli-core-functionality.md b/docs/development/cli-core-functionality.md index 06b8e84b..25be75d3 100644 --- a/docs/development/cli-core-functionality.md +++ b/docs/development/cli-core-functionality.md @@ -46,6 +46,43 @@ Progressive help is part of that parser contract: - `coven help ` should stay equivalent in useful content to `coven --help`. - Hidden/internal entrypoints such as `process-supervisor` and `coven daemon serve` stay absent from the public help surfaces and JSON catalog. +## JSON help contract + +`coven help --all --json` prints the public command catalog as a single deterministic JSON document on stdout. Automation parses this document instead of scraping human help output. The example below is abbreviated; the real document lists every public command across all six groups. + +```json +{ + "schemaVersion": 1, + "groups": [ + { + "id": "start-and-launch", + "title": "Start and launch", + "commands": [ + { + "name": "doctor", + "summary": "Check local setup and print next steps (exits 1 when a blocking problem is found)", + "docsUrl": "https://docs.opencoven.ai/docs/cli/doctor" + } + ] + } + ] +} +``` + +The contract to preserve: + +- `schemaVersion` is a number, currently `1`. The key set of a given schema version is exact: no missing fields and no extras. Changing the shape or field semantics means shipping a new schema version, not mutating this one. +- `groups` is a non-empty array. Group ids are lowercase kebab-case and fixed in this order: `start-and-launch`, `configure-and-extend`, `session-lifecycle`, `observe-your-coven`, `coordinate-parallel-work`, `repair-and-administer`. +- Each command carries a kebab-case `name`, a one-line `summary`, and an absolute `docsUrl` on the stable `https://docs.opencoven.ai/docs/` origin with no query parameters and a lowercase kebab-case fragment when one is present. +- Output is byte-identical across runs and machines: fixed group and command ordering, no ANSI escapes even under `--color=always`, no machine-specific paths, and no dependence on locale, clock, or environment. stderr stays empty on success. +- Coverage is complete and leak-free: every public top-level command appears exactly once, and internal entrypoints such as `process-supervisor` and `coven daemon serve` never appear. + +Drift fails loudly instead of silently: `crates/coven-cli/src/help.rs` errors at render time when a public command lacks catalog metadata or when the catalog names an unknown command, so adding, renaming, or hiding a public command requires updating `HELP_GROUPS` in the same change. `scripts/export-cli-help-contract.mjs` validates a catalog snapshot's shape, leak checks, and URL constraints from a built binary, which keeps packaged consumers and CI honest: + +```sh +node scripts/export-cli-help-contract.mjs --binary target/debug/coven --output help-contract.json +``` + ## Access paths developers should exercise Run these from a clean, representative project directory. They are ordered from read-only inspection to daemon activation; the final launch is intentionally opt-in. diff --git a/docs/guides/automation-json.md b/docs/guides/automation-json.md index 197b7ed2..b7b6c9a9 100644 --- a/docs/guides/automation-json.md +++ b/docs/guides/automation-json.md @@ -45,6 +45,14 @@ coven sessions --json | jq '.sessions[] | { id, harness, status, title }' Use `coven sessions --all --json` only when archived history is relevant. Treat ids as opaque strings and do not record session titles or event content in public logs without a privacy review. +## Discover the command inventory without scraping help + +```sh +coven help --all --json | jq -r '.groups[] | .title, (.commands[] | " \(.name) \(.summary)")' +``` + +The catalog is a deterministic, versioned JSON contract (`schemaVersion: 1`): fixed ordering, no ANSI escapes, and one entry per public command. Parse it instead of scraping `--help` output, and gate on `schemaVersion` before trusting new fields. The full field reference lives in the [developer core-functionality guide](/development/cli-core-functionality). + ## Pick the right integration boundary - Use the CLI JSON commands for local shell automation and maintainer checks. diff --git a/docs/index.md b/docs/index.md index 955b1539..8858eed6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,3 +28,5 @@ implementation: implementation decisions, plans, and historical records. See [`DOCS-MAINTENANCE.md`](DOCS-MAINTENANCE.md) before adding or moving a page. +Its public-doc directory boundary defines which directories may contain only +canonical pointers or source-adjacent exceptions. diff --git a/docs/install/cargo.md b/docs/install/cargo.md index 9b2bcc56..8033c1d4 100644 --- a/docs/install/cargo.md +++ b/docs/install/cargo.md @@ -1,73 +1,10 @@ --- -summary: "Build and install Coven directly from crates.io with cargo." -read_when: - - You prefer building Rust binaries yourself title: "Install via cargo" -description: "Install Coven from source with cargo: build the Rust daemon and CLI, drop the binary on PATH, and verify the install with coven doctor." +description: "Pointer to the canonical Coven install guidance." --- -# Install via cargo +Canonical install guidance, including the source-checkout route: +**https://docs.opencoven.ai/docs/guide/install** -Use this route when you want to build the Rust CLI yourself. For most users, [Install via npm](/install/npm) is the shorter path. - -## From a checkout - -```sh -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build -p coven-cli --release -mkdir -p "$HOME/.local/bin" -cp target/release/coven "$HOME/.local/bin/coven" -coven doctor -``` - -On Windows, copy `target\release\coven.exe` to a directory on `PATH`, then open a new terminal and run: - -```powershell -coven doctor -``` - -## Running without copying - -From the repository checkout: - -```sh -cargo run -p coven-cli -- doctor -cargo run -p coven-cli -- daemon start -cargo run -p coven-cli -- run codex "describe this repo" -``` - -## Harness setup - -Coven still needs a harness CLI for real agent work: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -Run `coven doctor` after installing or changing harness auth. - -## Updating a cargo-built binary - -```sh -cd /path/to/coven -git pull --ff-only -cargo build -p coven-cli --release -cp target/release/coven "$HOME/.local/bin/coven" -coven daemon restart -coven doctor -``` - -Use the Windows binary name when updating a Windows install. - -## Related - -- [Install from source](/install/from-source) -- [COVEN_HOME layout](/daemon/coven-home) -- [Updating Coven](/install/updating) +Contributor build and verification instructions remain in +[`CONTRIBUTING.md`](../../CONTRIBUTING.md). diff --git a/docs/install/coven-home.md b/docs/install/coven-home.md index c4237cdc..45ea51ac 100644 --- a/docs/install/coven-home.md +++ b/docs/install/coven-home.md @@ -1,35 +1,10 @@ --- -summary: "What lives under COVEN_HOME and how to relocate it." -read_when: - - Customizing where Coven keeps state title: "COVEN_HOME layout" -description: "How to lay out COVEN_HOME on a fresh install: the SQLite ledger, append-only event log, sockets, and per-session directories the daemon expects." +description: "Pointer to the canonical daemon configuration guidance." --- -`COVEN_HOME` is Coven's local state directory. If you do not set it, Coven uses `/.coven`. +Canonical daemon configuration guidance, including relocating `COVEN_HOME`: +**https://docs.opencoven.ai/docs/daemon/configuration** -Coven resolves `` from the normal platform home directory. On Windows this includes `USERPROFILE` and `HOMEDRIVE` + `HOMEPATH`, so `coven doctor` should not require a Unix-style `HOME` variable. - -The directory contains: - -- `coven.sqlite3` — the local session ledger; -- `daemon.json` and daemon sockets/pipes — local daemon metadata; -- `sessions/` and event logs — per-session artifacts; -- `familiars.toml` — optional local familiar declarations; -- `adapters/` — trusted local harness adapter manifests, including recipes created by `coven adapter install `. - -Override it only when you want Coven state somewhere else: - -```sh -export COVEN_HOME="$HOME/.coven" -coven doctor -``` - -PowerShell: - -```powershell -$env:COVEN_HOME="$env:USERPROFILE\.coven" -coven doctor -``` - -See [Install overview](/install/index) for the broader install flow. +The normative source-adjacent state-layout contract remains in +[`../daemon/coven-home.md`](../daemon/coven-home.md). diff --git a/docs/install/docker.md b/docs/install/docker.md index 670142fe..656796af 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -1,88 +1,7 @@ --- -summary: "Run the Coven daemon inside a Docker container." -read_when: - - Containerizing Coven for CI or homelab use title: "Docker" -description: "Run Coven in Docker: a containerized daemon plus harness CLIs, with bind mounts for COVEN_HOME and the project root for each session." +description: "Pointer to the canonical Coven deployment guidance." --- -# Docker - -Docker is an advanced setup path. This repository does not define a canonical Coven application image in the install docs; build your own image when you need container isolation for CI, demos, or a homelab. - -Use native installs for normal workstation use: [macOS](/install/macos), [Linux](/install/linux), [Windows](/install/windows), or [WSL2](/install/wsl2). - -## Minimal source-built image - -Create a Dockerfile in your own deployment repo: - -```Dockerfile -FROM rust:1-bookworm AS build -WORKDIR /src -COPY . . -RUN cargo build -p coven-cli --release - -FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates nodejs npm git \ - && rm -rf /var/lib/apt/lists/* -COPY --from=build /src/target/release/coven /usr/local/bin/coven -ENV COVEN_HOME=/var/lib/coven -WORKDIR /workspace -CMD ["coven", "doctor"] -``` - -Build it from a Coven source checkout: - -```sh -docker build -t coven-local . -``` - -## Run with explicit mounts - -```sh -mkdir -p "$HOME/.coven-container" -docker run --rm -it \ - -e COVEN_HOME=/var/lib/coven \ - -v "$HOME/.coven-container:/var/lib/coven" \ - -v "$PWD:/workspace" \ - -w /workspace \ - coven-local coven doctor -``` - -For real harness work, the container must also contain and authenticate the harness CLI. Provider credentials remain owned by that harness, not by Coven. - -## First container session - -```sh -docker run --rm -it \ - -e COVEN_HOME=/var/lib/coven \ - -v "$HOME/.coven-container:/var/lib/coven" \ - -v "$PWD:/workspace" \ - -w /workspace \ - coven-local coven daemon start -``` - -Then run a session in the same mounted environment: - -```sh -docker run --rm -it \ - -e COVEN_HOME=/var/lib/coven \ - -v "$HOME/.coven-container:/var/lib/coven" \ - -v "$PWD:/workspace" \ - -w /workspace \ - coven-local coven run codex "describe this repo" -``` - -## Notes - -- Bind-mount `COVEN_HOME` if you want session history to survive container exits. -- Bind-mount the project root you intend to run in. -- Do not expose the Coven daemon socket over TCP by default. -- Run `coven doctor` inside the same image and environment that will launch sessions. - -## Related - -- [Headless server](/install/headless-server) -- [Podman](/install/podman) -- [COVEN_HOME layout](/daemon/coven-home) +Canonical deployment guidance, including manual container integrations: +**https://docs.opencoven.ai/docs/guide/deployments** diff --git a/docs/install/from-source.md b/docs/install/from-source.md index af4b9265..c7f25016 100644 --- a/docs/install/from-source.md +++ b/docs/install/from-source.md @@ -1,94 +1,10 @@ --- -summary: "Clone the repo and build coven with cargo." -read_when: - - Developing Coven or running unreleased changes title: "Install from source" -description: "Build and install Coven from source: clone the repo, build the Rust daemon and CLI with cargo, and drop the binary on PATH for daily use." +description: "Pointer to the canonical Coven install guidance." --- -# Install from source +Canonical install guidance, including the source-checkout route: +**https://docs.opencoven.ai/docs/guide/install** -Use a source checkout when you are contributing to Coven, testing unreleased changes, or running on a platform where the npm native package is not available. - -## Requirements - -- Rust stable. -- Git. -- A supported shell for your platform. -- At least one harness CLI if you want to launch real sessions. - -## Build and verify - -```sh -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build --workspace -cargo run -p coven-cli -- doctor -``` - -Run the binary through Cargo while developing: - -```sh -cargo run -p coven-cli -- daemon start -cargo run -p coven-cli -- run codex "describe this repo" -cargo run -p coven-cli -- sessions -``` - -## Install the built binary - -After building, copy the release binary to a directory on `PATH`: - -```sh -cargo build -p coven-cli --release -mkdir -p "$HOME/.local/bin" -cp target/release/coven "$HOME/.local/bin/coven" -coven doctor -``` - -On Windows, copy `target\release\coven.exe` to a directory on `PATH`. - -## Harness setup - -Install and authenticate a harness in the same shell environment: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -Then run: - -```sh -coven doctor -``` - -## Development checks - -Before changing daemon, session, attach, or ritual behavior, run the workspace -checks described in [CONTRIBUTING.md](../../CONTRIBUTING.md) and -[Documentation maintenance](/DOCS-MAINTENANCE). - -For docs-only install work: - -```sh -python scripts/check-secrets.py -git diff --check -``` - -For code work: - -```sh -cargo fmt --check -cargo test --workspace --locked -``` - -## Related - -- [Install via npm](/install/npm) -- [Install via cargo](/install/cargo) -- [Linux install](/install/linux) +Contributor build and verification instructions remain in +[`CONTRIBUTING.md`](../../CONTRIBUTING.md). diff --git a/docs/install/headless-server.md b/docs/install/headless-server.md index e5adbe6a..53b91d20 100644 --- a/docs/install/headless-server.md +++ b/docs/install/headless-server.md @@ -1,99 +1,7 @@ --- -summary: "Install Coven on a headless Linux server with systemd." -read_when: - - Running Coven without a desktop title: "Headless server" -description: "Install Coven on a headless server: daemon-only setup, no TUI, with systemd or launchd supervision and remote access through SSH tunnels." +description: "Pointer to the canonical Coven deployment guidance." --- -# Headless server - -On a headless host, install Coven the same way as Linux, then operate it through SSH and explicit daemon commands. - -```sh -npm install -g @opencoven/cli -coven doctor -``` - -If the npm native package is not available on the server distribution, use [Install from source](/install/from-source). - -## Server layout - -Choose a dedicated user and keep state under that user's home directory: - -```sh -export COVEN_HOME="$HOME/.coven" -mkdir -p "$COVEN_HOME" -coven doctor -``` - -Keep `COVEN_HOME` on a local disk owned by the service user. Avoid sharing one state directory between multiple Unix users. - -## Harness setup - -Install and authenticate the harness CLI as the same user that will run Coven: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -Run: - -```sh -coven doctor -``` - -## Daemon lifecycle - -Start and inspect the daemon over SSH: - -```sh -coven daemon start -coven daemon status -``` - -Restart after updates or environment changes: - -```sh -coven daemon restart -coven doctor -``` - -Stop it before changing ownership, moving `COVEN_HOME`, or rebuilding the binary: - -```sh -coven daemon stop -``` - -## First remote session - -```sh -cd /path/to/project -coven run codex "summarize the current branch" -coven sessions -``` - -Use `coven attach ` to follow a live session from a later SSH connection. - -## Supervisor note - -The CLI daemon commands are the stable operational surface. If you wrap them with systemd, launchd, tmux, or another supervisor, keep the environment explicit: - -```sh -COVEN_HOME="$HOME/.coven" coven daemon start -``` - -Make sure the supervisor has the same `PATH` that exposes `coven`, `codex`, and `claude`. - -## Related - -- [Linux install](/install/linux) -- [COVEN_HOME layout](/daemon/coven-home) -- [Daemon lifecycle](/daemon/lifecycle) -- [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) +Canonical headless and cloud-host deployment guidance: +**https://docs.opencoven.ai/docs/guide/deployments** diff --git a/docs/install/index.md b/docs/install/index.md index 832dbae3..7343cdda 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -1,123 +1,13 @@ --- -summary: "All ways to install Coven on a workstation or server." -read_when: - - Choosing how to install Coven title: "Install overview" -description: "Install overview for Coven: pick a platform and a method (npm, cargo, Docker, Nix, source) and verify the daemon with coven doctor." +description: "Pointer to the canonical Coven install guidance." --- -# Install overview +Canonical install guidance: **https://docs.opencoven.ai/docs/guide/install** -Use this page to pick the right Coven install path, then verify the setup the same way on every platform: +Platform notes and service-manager guidance live at +**https://docs.opencoven.ai/docs/guide/platforms** and +**https://docs.opencoven.ai/docs/guide/deployments**. -```sh -coven doctor -coven daemon start -coven daemon status -``` - -After the daemon is running, launch the first session from a project directory: - -```sh -cd /path/to/project -coven run codex "describe this repo" -``` - -Or use Claude Code: - -```sh -coven run claude "describe this repo" -``` - -## Choose your route - -| Environment | Recommended path | Notes | -| --- | --- | --- | -| macOS Apple Silicon | [npm wrapper](/install/npm) or [macOS install](/install/macos) | Uses the universal `@opencoven/cli` package and the native macOS package. | -| Intel macOS x64 | [npm wrapper](/install/npm) or [macOS install](/install/macos) | Uses the universal `@opencoven/cli` package and the Intel native macOS package. | -| glibc-based Linux x64 | [npm wrapper](/install/npm) or [Linux install](/install/linux) | Alpine/musl is not part of the npm binary target today; build from source there. | -| Windows x64 | [Windows install](/install/windows) | Run Coven and harness CLIs from the same PowerShell, Windows Terminal, or native Windows shell. | -| WSL2 | [WSL2 install](/install/wsl2) | Treat WSL2 as a Linux environment and keep `COVEN_HOME` on the WSL filesystem. | -| Contributor checkout | [Install from source](/install/from-source) | Use this for unreleased changes and local development. | -| Rust-first install | [Install via cargo](/install/cargo) | Build the Rust CLI yourself and put the binary on `PATH`. | -| Server or automation host | [Headless server](/install/headless-server) | Use daemon commands over SSH or supervisor-managed shells. | -| macOS background service | [launchd service](/install/launchd) | Optional user-agent wrapper around `coven daemon start`. | -| Linux background service | [systemd unit](/install/systemd) | Optional user service wrapper around `coven daemon start`. | -| Raspberry Pi | [Raspberry Pi](/install/raspberry-pi) | Build from source on arm64 and keep state on persistent storage. | -| Container experiments | [Docker](/install/docker) or [Podman](/install/podman) | Build your own image; bind-mount state and project roots explicitly. | -| Nix-managed shell | [Nix](/install/nix) | Use Nix to pin prerequisites, then build or run Coven inside that shell. | - -## Baseline requirements - -- Node.js 18+ for the npm wrapper path. -- Git for source checkouts and project-root detection. -- Rust stable only when building from source or with cargo. -- At least one externally installed harness CLI with a Coven built-in adapter - on `PATH`: Codex, Claude Code, or GitHub Copilot CLI. - -Install and authenticate a harness before expecting `coven run` to launch work: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -```sh -npm install -g @github/copilot -copilot login -``` - -Then run: - -```sh -coven doctor -``` - -`doctor` reports store readiness, daemon/local IPC status, project-root hints, -and whether supported harness CLIs are available from the same shell. - -## State directory - -By default, Coven stores local state under `/.coven`. Override it only when you need a separate state root: - -```sh -export COVEN_HOME="$HOME/.coven" -coven doctor -``` - -PowerShell: - -```powershell -$env:COVEN_HOME="$env:USERPROFILE\.coven" -coven doctor -``` - -See [COVEN_HOME layout](/daemon/coven-home) for what lives inside that directory. - -## Common verification loop - -Use the same loop after install, after updates, and after changing harness auth: - -```sh -coven --version -coven doctor -coven daemon restart -coven daemon status -cd /path/to/project -coven run codex "say hello from Coven" -coven sessions -``` - -If `doctor` reports a missing harness after installation, open a new terminal so `PATH` refreshes, then run `coven doctor` again from the shell where you will use Coven. - -## Related - -- [Getting started](https://docs.opencoven.ai/docs/guide/getting-started) -- [Quickstart](/start/quickstart) -- [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) -- [CLI reference](/reference/cli) +The source-adjacent `COVEN_HOME` state-layout contract remains in +[`../daemon/coven-home.md`](../daemon/coven-home.md). diff --git a/docs/install/launchd.md b/docs/install/launchd.md index 2cf44fbe..db699e2f 100644 --- a/docs/install/launchd.md +++ b/docs/install/launchd.md @@ -1,92 +1,9 @@ --- -summary: "Run the Coven daemon as a launchd user agent on macOS." -read_when: - - Keeping the daemon up on macOS title: "launchd service" -description: "Run the Coven daemon under launchd on macOS: write a plist, load it, and have launchctl supervise the daemon across reboots and crashes." +description: "Pointer to the canonical Coven service-manager guidance." --- -# launchd service +Canonical service-manager guidance, including the macOS `launchd` route: +**https://docs.opencoven.ai/docs/guide/deployments** -Use a launchd user agent when you want the Coven daemon started automatically for your macOS user. Install and verify Coven manually first: - -```sh -npm install -g @opencoven/cli -coven doctor -coven daemon start -coven daemon status -coven daemon stop -``` - -## User agent plist - -Create the LaunchAgents directory: - -```sh -mkdir -p "$HOME/Library/LaunchAgents" -``` - -Write `~/Library/LaunchAgents/coven.plist`: - -```xml - - - - Label - coven - - ProgramArguments - - /usr/bin/env - coven - daemon - start - - - EnvironmentVariables - - COVEN_HOME - $HOME/.coven - - - RunAtLoad - - - -``` - -Replace `$HOME` with your absolute home path before loading the plist. launchd does not expand shell variables inside plist strings. - -Load and verify: - -```sh -launchctl bootstrap gui/UID ~/Library/LaunchAgents/coven.plist -launchctl kickstart -k gui/UID/coven -coven daemon status -``` - -Replace `UID` with the output of `id -u`. - -Unload: - -```sh -launchctl bootout gui/UID/coven -``` - -## PATH and harnesses - -launchd uses a smaller environment than your interactive shell. If `coven`, `codex`, or `claude` is installed in a user-local directory, prefer absolute paths in `ProgramArguments` or add a `PATH` entry under `EnvironmentVariables`. - -After changing the plist: - -```sh -launchctl bootout gui/UID/coven -launchctl bootstrap gui/UID ~/Library/LaunchAgents/coven.plist -coven doctor -``` - -## Related - -- [macOS install](/install/macos) -- [COVEN_HOME layout](/daemon/coven-home) -- [Updating Coven](/install/updating) +Platform-specific macOS behavior: **https://docs.opencoven.ai/docs/guide/platforms** diff --git a/docs/install/linux.md b/docs/install/linux.md index 1d0bc8b5..ca56acf9 100644 --- a/docs/install/linux.md +++ b/docs/install/linux.md @@ -1,99 +1,8 @@ --- -summary: "Install Coven on common Linux distros." -read_when: - - Installing on Linux title: "Linux install" -description: "Install Coven on Linux: install the @opencoven/cli wrapper, place the daemon binary on PATH, and verify the install with coven doctor." +description: "Pointer to the canonical Linux platform guidance." --- -# Linux install +Canonical Linux platform guidance: **https://docs.opencoven.ai/docs/guide/platforms** -Use the npm wrapper on glibc-based Linux x64 systems: - -```sh -npm install -g @opencoven/cli -coven --version -coven doctor -``` - -The universal wrapper selects the native Linux x64 package. Alpine and other musl-based environments should use [Install from source](/install/from-source). - -## Baseline packages - -Install Node.js 18+ for the npm wrapper. Install Git for project-root detection and source checkouts. - -Debian or Ubuntu: - -```sh -sudo apt-get update -sudo apt-get install -y nodejs npm git ca-certificates -``` - -Fedora: - -```sh -sudo dnf install -y nodejs npm git ca-certificates -``` - -Arch: - -```sh -sudo pacman -S --needed nodejs npm git ca-certificates -``` - -## Harness setup - -Install and authenticate at least one harness CLI: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -Run `coven doctor` from the same shell after installing harnesses. - -## First session - -```sh -cd /path/to/project -coven daemon start -coven daemon status -coven run codex "describe this repo" -coven sessions -``` - -Use Claude Code instead with: - -```sh -coven run claude "describe this repo" -``` - -## COVEN_HOME - -The default state directory is: - -```sh -$HOME/.coven -``` - -Keep it on the Linux filesystem, not a network mount, when possible. To override: - -```sh -export COVEN_HOME="$HOME/.local/share/coven" -coven doctor -``` - -## Server use - -For a non-desktop host, start with this page, then read [Headless server](/install/headless-server) for daemon lifecycle and SSH-oriented operation. - -## Related - -- [Install via npm](/install/npm) -- [WSL2 install](/install/wsl2) -- [Install from source](/install/from-source) +Install-method selection: **https://docs.opencoven.ai/docs/guide/install** diff --git a/docs/install/macos.md b/docs/install/macos.md index fc1f939a..883f64a3 100644 --- a/docs/install/macos.md +++ b/docs/install/macos.md @@ -1,95 +1,8 @@ --- -summary: "Install Coven on macOS via npm, Homebrew, or source." -read_when: - - Installing on macOS title: "macOS install" -description: "Install Coven on macOS: install the @opencoven/cli wrapper, place the daemon binary on PATH, and supervise the daemon with launchd." +description: "Pointer to the canonical macOS platform guidance." --- -# macOS install +Canonical macOS platform guidance: **https://docs.opencoven.ai/docs/guide/platforms** -Use the npm wrapper on macOS unless you are developing Coven itself. - -```sh -npm install -g @opencoven/cli -coven --version -coven doctor -``` - -The universal wrapper selects `@opencoven/cli-macos` on Apple Silicon and -`@opencoven/cli-macos-x64` on Intel macOS. Use the source install path only -when developing Coven itself or when a release is unavailable for your target. - -## Harness setup - -Install and authenticate at least one harness CLI from the same shell where you run Coven: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -Then verify: - -```sh -coven doctor -``` - -If `doctor` reports a missing harness, open a new terminal and check the command directly: - -```sh -command -v codex -command -v claude -``` - -## First session - -```sh -cd /path/to/project -coven daemon start -coven daemon status -coven run codex "describe this repo" -coven sessions -``` - -Use `coven run claude "describe this repo"` when Claude Code is the configured harness. - -## COVEN_HOME - -The default state directory is: - -```sh -$HOME/.coven -``` - -To isolate state for a demo, test account, or project: - -```sh -export COVEN_HOME="$HOME/.coven-demo" -coven doctor -coven daemon start -``` - -Keep `COVEN_HOME` on a local disk owned by your user. See [COVEN_HOME layout](/daemon/coven-home). - -## Source install for contributors - -```sh -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build --workspace -cargo run -p coven-cli -- doctor -``` - -Use [Install from source](/install/from-source) for the full contributor path. - -## Related - -- [Install via npm](/install/npm) -- [Updating Coven](/install/updating) -- [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) +Install-method selection: **https://docs.opencoven.ai/docs/guide/install** diff --git a/docs/install/nix.md b/docs/install/nix.md index 878c84f4..cf467871 100644 --- a/docs/install/nix.md +++ b/docs/install/nix.md @@ -1,83 +1,7 @@ --- -summary: "Reproducible Coven environment with Nix flakes." -read_when: - - You use Nix to manage tooling title: "Nix" -description: "Install Coven with Nix: a reproducible flake-based setup that pins the daemon, CLI, and supported harnesses across hosts and developer machines." +description: "Pointer to the canonical Coven deployment guidance." --- -# Nix - -Use Nix to pin build prerequisites and harness tooling around a source checkout. This repository does not currently use this page to promise an official Coven flake output. - -For the shortest install, use [Install via npm](/install/npm). For reproducible development shells, use the pattern below. - -## Development shell - -Create a local `flake.nix` in your own workspace: - -```nix -{ - description = "Coven development shell"; - - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - outputs = { nixpkgs, ... }: - let - system = "x86_64-linux"; - pkgs = import nixpkgs { inherit system; }; - in { - devShells.${system}.default = pkgs.mkShell { - packages = [ - pkgs.rustc - pkgs.cargo - pkgs.pkg-config - pkgs.openssl - pkgs.nodejs_22 - pkgs.git - ]; - }; - }; -} -``` - -Enter the shell and build from source: - -```sh -nix develop -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build --workspace -cargo run -p coven-cli -- doctor -``` - -## Harness setup - -Install harness CLIs inside the environment where you run Coven, or add them to your Nix shell when available from your package set. - -For npm-managed harnesses: - -```sh -npm install -g @openai/codex -codex login -npm install -g @anthropic-ai/claude-code -claude doctor -coven doctor -``` - -## State isolation - -Use an explicit state directory per Nix shell or host: - -```sh -export COVEN_HOME="$PWD/.coven-state" -coven doctor -``` - -Do not commit `.coven-state` or any other `COVEN_HOME` contents. - -## Related - -- [Install from source](/install/from-source) -- [Install via cargo](/install/cargo) -- [COVEN_HOME layout](/daemon/coven-home) +Canonical deployment guidance, including Nix as an environment or build route: +**https://docs.opencoven.ai/docs/guide/deployments** diff --git a/docs/install/npm.md b/docs/install/npm.md index 9e002c8c..e05ff537 100644 --- a/docs/install/npm.md +++ b/docs/install/npm.md @@ -1,113 +1,9 @@ --- -summary: "Install the @opencoven/cli wrapper from npm." -read_when: - - Using npm or pnpm to install Coven title: "Install via npm" -description: "Install Coven with npm: run npm install -g @opencoven/cli to fetch the wrapper plus a prebuilt native daemon binary for supported macOS, Linux, and Windows targets." +description: "Pointer to the canonical Coven install guidance." --- -# Install via npm +Canonical install guidance, including the `npm install -g @opencoven/cli` +wrapper route: **https://docs.opencoven.ai/docs/guide/install** -The fastest workstation install is the universal npm wrapper: - -```sh -npm install -g @opencoven/cli -coven --version -coven doctor -``` - -The wrapper exposes the `coven` command and selects the native package for the current platform. - -The packaged loopback-only dashboard is a separate opt-in install on its own -release train. The wrapper stays thin: it depends only on the native binary for -your platform, so a CLI install never pulls the dashboard's application -dependencies. Install it when you want `coven memory open`: - -```sh -npm install -g @opencoven/coven-memory-dashboard -coven memory open -``` - -Without it, `coven memory open` prints this install instruction and exits; every -other Coven command is unaffected. - -Upgrading from a wrapper older than 0.4.1 removes a dashboard that arrived as an -implicit dependency, so `coven memory open` stops working until you install the -companion explicitly with the command above. Nothing else changes. - -The wrapper passes only the resolved dashboard entrypoint and the current Node -executable to the native CLI. It does not put memory content, daemon transport -proofs, or credentials in the environment. - -The core npm wrapper supports Node.js 18 or newer. The dashboard companion -requires Node.js 24 or newer. On Node.js 18–23, `coven memory open` prints an -upgrade instruction; list output and every other Coven command remain -available. - -## Supported npm targets - -| Platform | Native package | -| --- | --- | -| macOS Apple Silicon | `@opencoven/cli-macos` | -| Intel macOS x64 | `@opencoven/cli-macos-x64` | -| glibc-based Linux x64 | `@opencoven/cli-linux-x64` | -| Windows x64 | `@opencoven/cli-windows` | - -If the wrapper cannot find the native package, reinstall without disabling optional dependencies: - -```sh -npm uninstall -g @opencoven/cli -npm install -g @opencoven/cli -coven doctor -``` - -On Linux, use a glibc-based distribution for the prebuilt package. For Alpine or another musl-based environment, use [Install from source](/install/from-source). - -If Coven was installed as a direct native binary, the dashboard is found on -`PATH` rather than through the wrapper. The same global install puts it there: - -```sh -npm install -g @opencoven/coven-memory-dashboard -``` - -## Install harness CLIs - -Coven supervises existing harness CLIs. Install and authenticate at least one: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -Run `coven doctor` again after harness installation. If a harness is still missing, open a new terminal and verify the harness command is on `PATH` in that same shell. - -## First run - -```sh -cd /path/to/project -coven doctor -coven daemon start -coven run codex "describe this repo" -coven sessions -``` - -Use Claude Code instead when that is the authenticated harness: - -```sh -coven run claude "describe this repo" -``` - -## Updating - -```sh -npm update -g @opencoven/cli -coven daemon restart -coven doctor -``` - -See [Updating Coven](/install/updating) before updating shared automation hosts or long-running daemon environments. +Install troubleshooting: **https://docs.opencoven.ai/docs/cli/install-debugging** diff --git a/docs/install/podman.md b/docs/install/podman.md index 02e6f7d5..c78dd49b 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -1,77 +1,7 @@ --- -summary: "Run Coven under Podman with rootless containers." -read_when: - - Daemonless container hosting title: "Podman" -description: "Run Coven under Podman: a rootless containerized daemon plus harness CLIs, with bind mounts for COVEN_HOME and the project root per session." +description: "Pointer to the canonical Coven deployment guidance." --- -# Podman - -Podman is useful for rootless container experiments and homelab hosts. For ordinary workstation setup, prefer the native platform pages. - -This page assumes you build a local image from the Coven source checkout. There is no install-docs promise of an official Podman image. - -## Build a local image - -Use the Dockerfile pattern from [Docker](/install/docker), then build with Podman: - -```sh -podman build -t coven-local . -``` - -## Run doctor with persistent state - -```sh -mkdir -p "$HOME/.coven-container" -podman run --rm -it \ - -e COVEN_HOME=/var/lib/coven \ - -v "$HOME/.coven-container:/var/lib/coven:Z" \ - -v "$PWD:/workspace:Z" \ - -w /workspace \ - coven-local coven doctor -``` - -Drop the `:Z` label suffix on systems that do not use SELinux. - -## Harness setup - -Install and authenticate harness CLIs inside the container image or in a derived image: - -```Dockerfile -RUN npm install -g @openai/codex @anthropic-ai/claude-code -``` - -Then verify inside the same container environment: - -```sh -podman run --rm -it \ - -e COVEN_HOME=/var/lib/coven \ - -v "$HOME/.coven-container:/var/lib/coven:Z" \ - -v "$PWD:/workspace:Z" \ - -w /workspace \ - coven-local coven doctor -``` - -## First session - -```sh -podman run --rm -it \ - -e COVEN_HOME=/var/lib/coven \ - -v "$HOME/.coven-container:/var/lib/coven:Z" \ - -v "$PWD:/workspace:Z" \ - -w /workspace \ - coven-local coven run codex "describe this repo" -``` - -## Notes - -- Rootless Podman changes UID/GID mappings. Keep mounted state owned by the user that runs Podman. -- Use one mounted `COVEN_HOME` per environment. -- Run `coven doctor` after every image or mount change. - -## Related - -- [Docker](/install/docker) -- [Headless server](/install/headless-server) -- [Linux install](/install/linux) +Canonical deployment guidance, including manual container integrations: +**https://docs.opencoven.ai/docs/guide/deployments** diff --git a/docs/install/raspberry-pi.md b/docs/install/raspberry-pi.md index 25140066..a04fe260 100644 --- a/docs/install/raspberry-pi.md +++ b/docs/install/raspberry-pi.md @@ -1,90 +1,9 @@ --- -summary: "Run Coven on Raspberry Pi as a low-power home agent host." -read_when: - - Hosting Coven on a Pi title: "Raspberry Pi" -description: "Install Coven on a Raspberry Pi: arm64 daemon binary, COVEN_HOME on persistent storage, and systemd supervision for headless agent work." +description: "Pointer to the canonical Raspberry Pi platform guidance." --- -# Raspberry Pi +Canonical Raspberry Pi platform guidance: +**https://docs.opencoven.ai/docs/guide/platforms** -Raspberry Pi is a source-build path today. Use a 64-bit Raspberry Pi OS image and keep `COVEN_HOME` on persistent local storage. - -## Install prerequisites - -```sh -sudo apt-get update -sudo apt-get install -y git curl build-essential pkg-config libssl-dev nodejs npm ca-certificates -``` - -Install Rust stable if it is not already present: - -```sh -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -. "$HOME/.cargo/env" -``` - -## Build Coven - -```sh -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build -p coven-cli --release -mkdir -p "$HOME/.local/bin" -cp target/release/coven "$HOME/.local/bin/coven" -coven doctor -``` - -Make sure `$HOME/.local/bin` is on `PATH`. - -## Harness setup - -Install only harness CLIs that support your Pi architecture and auth flow. Then verify from the same shell: - -```sh -coven doctor -``` - -If Codex or Claude Code is installed with npm: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -## State and daemon - -Use an explicit state directory: - -```sh -export COVEN_HOME="$HOME/.coven" -coven daemon start -coven daemon status -``` - -For always-on operation, use [systemd unit](/install/systemd) after the manual `coven doctor` path works. - -## First session - -```sh -cd /path/to/project -coven run codex "describe this repo" -coven sessions -``` - -## Notes - -- Build times can be long on small Pi models. -- Keep swap and disk space healthy before building Rust dependencies. -- Avoid storing `COVEN_HOME` on removable media that may disappear while the daemon is running. - -## Related - -- [Linux install](/install/linux) -- [Headless server](/install/headless-server) -- [systemd unit](/install/systemd) +Install-method selection: **https://docs.opencoven.ai/docs/guide/install** diff --git a/docs/install/systemd.md b/docs/install/systemd.md index bd2f15fe..3e7cb5d7 100644 --- a/docs/install/systemd.md +++ b/docs/install/systemd.md @@ -1,87 +1,9 @@ --- -summary: "Run the Coven daemon as a systemd user unit." -read_when: - - Keeping the daemon up on Linux title: "systemd unit" -description: "Run the Coven daemon under systemd on Linux: a unit file, environment for COVEN_HOME, and journalctl access to daemon logs across reboots." +description: "Pointer to the canonical Coven service-manager guidance." --- -# systemd unit +Canonical service-manager guidance, including the Linux `systemd --user` route: +**https://docs.opencoven.ai/docs/guide/deployments** -Use a systemd user unit when you want the Coven daemon available after login on a Linux workstation or server. Install Coven and verify it manually first: - -```sh -npm install -g @opencoven/cli -coven doctor -coven daemon start -coven daemon status -coven daemon stop -``` - -## User unit - -Create the user unit directory: - -```sh -mkdir -p "$HOME/.config/systemd/user" -``` - -Write `~/.config/systemd/user/coven-daemon.service`: - -```ini -[Unit] -Description=Coven daemon -After=default.target - -[Service] -Type=oneshot -RemainAfterExit=yes -Environment=COVEN_HOME=%h/.coven -ExecStart=/usr/bin/env coven daemon start -ExecStop=/usr/bin/env coven daemon stop -ExecReload=/usr/bin/env coven daemon restart - -[Install] -WantedBy=default.target -``` - -Load and start it: - -```sh -systemctl --user daemon-reload -systemctl --user enable --now coven-daemon.service -systemctl --user status coven-daemon.service -coven daemon status -``` - -If you need the user service to start without an active login session, enable linger for that Linux user: - -```sh -loginctl enable-linger "$USER" -``` - -## PATH and harnesses - -The service must resolve the same commands that `coven doctor` reports from your shell. If `coven`, `codex`, or `claude` is installed under a user-local path, add an explicit `Environment=PATH=...` line to the unit. - -After changing PATH, harness auth, or `COVEN_HOME`: - -```sh -systemctl --user daemon-reload -systemctl --user restart coven-daemon.service -coven doctor -``` - -## Logs - -```sh -journalctl --user -u coven-daemon.service --since today -``` - -Use `coven daemon status` as the product-level health check; systemd only tells you whether the wrapper command ran. - -## Related - -- [Linux install](/install/linux) -- [Headless server](/install/headless-server) -- [COVEN_HOME layout](/daemon/coven-home) +Platform-specific Linux behavior: **https://docs.opencoven.ai/docs/guide/platforms** diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index 9715dcd5..da8433b5 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -1,99 +1,6 @@ --- -summary: "How to remove Coven cleanly without losing project sessions." -read_when: - - Removing Coven from a workstation title: "Uninstalling Coven" -description: "Uninstall Coven cleanly: stop the daemon, remove the wrapper, and decide whether to keep or wipe COVEN_HOME and the session ledger." +description: "Pointer to the canonical uninstall guidance." --- -# Uninstalling Coven - -Uninstall has two separate decisions: - -1. Remove the `coven` command. -2. Keep or delete `COVEN_HOME`, which contains local session history, sockets, logs, and keys. - -Stop the daemon first: - -```sh -coven daemon stop -``` - -## npm wrapper - -```sh -npm uninstall -g @opencoven/cli -``` - -Verify the command is gone: - -```sh -command -v coven -``` - -PowerShell: - -```powershell -Get-Command coven -``` - -If another install path still exposes `coven`, remove that binary or adjust `PATH`. - -## Source or cargo install - -Remove the binary you copied onto `PATH`: - -```sh -rm "$HOME/.local/bin/coven" -``` - -Windows PowerShell: - -```powershell -Remove-Item "$env:USERPROFILE\.local\bin\coven.exe" -``` - -Adjust the path if you installed the binary somewhere else. - -## Keep or delete state - -To preserve sessions for a later reinstall, leave `COVEN_HOME` in place. - -To delete the default state directory on macOS, Linux, or WSL2: - -```sh -rm -rf "$HOME/.coven" -``` - -PowerShell: - -```powershell -Remove-Item -Recurse -Force "$env:USERPROFILE\.coven" -``` - -Only delete `COVEN_HOME` after confirming there are no sessions, logs, or local keys you need to keep. - -## Services - -If you installed a launchd user agent: - -```sh -launchctl bootout gui/UID/coven -rm ~/Library/LaunchAgents/coven.plist -``` - -Replace `UID` with the output of `id -u`. - -If you installed a systemd user unit: - -```sh -systemctl --user disable --now coven-daemon.service -rm "$HOME/.config/systemd/user/coven-daemon.service" -systemctl --user daemon-reload -``` - -## Related - -- [COVEN_HOME layout](/daemon/coven-home) -- [Install overview](/install/index) -- [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) +Canonical uninstall guidance: **https://docs.opencoven.ai/docs/cli/uninstall** diff --git a/docs/install/updating.md b/docs/install/updating.md index 253f3cbb..b95bddab 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -1,112 +1,7 @@ --- -summary: "How to update Coven and what release channels exist." -read_when: - - Moving to a newer version of Coven title: "Updating Coven" -description: "Update Coven safely: upgrade the @opencoven/cli wrapper, drain or stop the daemon, migrate the store, and verify the contract version on restart." +description: "Pointer to the canonical install-debugging and update-recovery guidance." --- -# Updating Coven - -Update the wrapper or binary, restart the daemon, then run the same verification loop you used at install time. - -## npm wrapper - -```sh -npm update -g @opencoven/cli -coven --version -coven daemon restart -coven doctor -``` - -If the native package is missing after update, reinstall the wrapper without disabling optional dependencies: - -```sh -npm uninstall -g @opencoven/cli -npm install -g @opencoven/cli -coven doctor -``` - -## Source checkout - -```sh -cd /path/to/coven -git pull --ff-only -cargo build -p coven-cli --release -cp target/release/coven "$HOME/.local/bin/coven" -coven daemon restart -coven doctor -``` - -On Windows, copy `target\release\coven.exe` to the directory where your shell resolves `coven`. - -## Harness updates - -Coven supervises harness CLIs; it does not own their provider credentials. Update and verify harnesses separately: - -```sh -npm update -g @openai/codex -codex login -``` - -```sh -npm update -g @anthropic-ai/claude-code -claude doctor -``` - -Then run: - -```sh -coven doctor -``` - -## Verification loop - -```sh -coven --version -coven doctor -coven daemon restart -coven daemon status -cd /path/to/project -coven run codex "say hello from the updated Coven install" -coven sessions -``` - -Use `coven run claude ...` when Claude Code is your active harness. - -## Rollback notes - -Before changing package source, binary location, or `COVEN_HOME`, stop the daemon: - -```sh -coven daemon stop -``` - -If an update leaves the daemon unreachable, run: - -```sh -coven daemon restart -coven doctor -``` - -If `doctor` points at a `PATH` problem, open a new shell and verify: - -```sh -command -v coven -command -v codex -command -v claude -``` - -PowerShell: - -```powershell -Get-Command coven -Get-Command codex -Get-Command claude -``` - -## Related - -- [Install overview](/install/index) -- [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) -- [Daemon lifecycle](/daemon/lifecycle) +Canonical update-recovery and install-debugging guidance: +**https://docs.opencoven.ai/docs/cli/install-debugging** diff --git a/docs/install/windows.md b/docs/install/windows.md index d838358c..79c6dffd 100644 --- a/docs/install/windows.md +++ b/docs/install/windows.md @@ -1,100 +1,8 @@ --- -summary: "Install Coven on native Windows." -read_when: - - Installing on Windows title: "Windows install" -description: "Install Coven on Windows: how to set up the wrapper, native daemon binary, COVEN_HOME, and harness CLIs on a Windows host or WSL2 environment." +description: "Pointer to the canonical Windows platform guidance." --- -# Windows install +Canonical Windows platform guidance: **https://docs.opencoven.ai/docs/guide/platforms** -Install the wrapper globally from PowerShell, Windows Terminal, or any terminal that can run Node.js packages: - -```powershell -npm install -g @opencoven/cli -coven doctor -``` - -The wrapper exposes the `coven` command and launches the native Windows binary -when the release package includes one for your platform. `coven doctor` is the -first verification step: it checks local state and reports whether supported -harness CLIs such as Codex, Claude Code, or GitHub Copilot CLI are available on -`PATH`. - -Use native Windows and WSL2 as separate Coven environments. If you install Coven in PowerShell, install the harness CLIs in PowerShell too. If you install Coven inside WSL2, follow [WSL2 install](/install/wsl2) and keep the daemon state inside WSL. - -## First run - -From a project directory: - -```powershell -coven -``` - -Bare `coven`, `coven chat`, and `coven tui` open the managed Coven interactive -UI powered by `coven-code`. On the first interactive run, Coven offers to -install the pinned engine if it is missing. The older in-process TUI is a -temporary compatibility fallback: explicitly set `COVEN_LEGACY_TUI=1` to use -it. It is deprecated and will be removed. - -You can also use the explicit CLI flow: - -```powershell -coven doctor -coven daemon start -coven run codex "fix the failing tests" -coven run claude "audit this branch" --think -coven sessions -``` - -Install and authenticate at least one harness CLI before expecting `coven run` to launch work. If `coven doctor` reports a missing harness, install that tool, open a new terminal so `PATH` is refreshed, and run `coven doctor` again. - -Codex: - -```powershell -npm install -g @openai/codex -codex login -``` - -Claude Code: - -```powershell -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -## Windows notes - -- `coven doctor` should work in PowerShell even when the `HOME` environment variable is absent. Coven resolves its default store from `COVEN_HOME`, `HOME`, `USERPROFILE`, `HOMEDRIVE` + `HOMEPATH`, or the platform home directory. -- Keep `COVEN_HOME` on a local path owned by your Windows user when you override it. -- To override the store path in PowerShell, use: - -```powershell -$env:COVEN_HOME="$env:USERPROFILE\.coven" -coven doctor -``` - -- Run Coven and your harness CLI from the same environment. A harness installed only inside WSL2 is not available to native Windows PowerShell unless you expose it separately. -- The legacy in-process TUI is only for temporary compatibility. Set - `COVEN_LEGACY_TUI=1` explicitly if a legacy workflow requires it; do not use - it as the default Windows interactive UI. - -## Verification loop - -```powershell -coven --version -coven doctor -coven daemon restart -coven daemon status -cd C:\path\to\project -coven run codex "describe this repo" -coven sessions -``` - -## Related - -- [Get started with Coven](https://docs.opencoven.ai/docs/guide/getting-started) -- [Install overview](/install/index) -- [Coven TUI](/start/coven-tui) -- [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) -- [CLI reference](/reference/cli) +Install-method selection: **https://docs.opencoven.ai/docs/guide/install** diff --git a/docs/install/wsl2.md b/docs/install/wsl2.md index d6821bae..2cd112aa 100644 --- a/docs/install/wsl2.md +++ b/docs/install/wsl2.md @@ -1,79 +1,8 @@ --- -summary: "Install Coven inside WSL2 for the full Unix-socket experience." -read_when: - - Installing on WSL2 title: "WSL2 install" -description: "Install Coven inside WSL2: run the Linux daemon binary, pin COVEN_HOME on the WSL filesystem, and connect Windows clients to the socket." +description: "Pointer to the canonical WSL2 platform guidance." --- -# WSL2 install +Canonical WSL2 platform guidance: **https://docs.opencoven.ai/docs/guide/platforms** -Inside WSL2, install Coven as Linux software. Keep Coven, harness CLIs, project files, and `COVEN_HOME` in the WSL environment for the least surprising daemon and PTY behavior. - -```sh -npm install -g @opencoven/cli -coven --version -coven doctor -``` - -The npm wrapper uses the Linux x64 native package when your WSL distribution is glibc-based. - -## Recommended layout - -Use Linux paths for projects and state: - -```sh -mkdir -p "$HOME/code" -cd "$HOME/code" -export COVEN_HOME="$HOME/.coven" -``` - -Avoid putting active Coven state under `/mnt/c` because Windows filesystem semantics can make socket, permission, and file-watch behavior harder to reason about. - -## Harness setup - -Install harness CLIs inside WSL2: - -```sh -npm install -g @openai/codex -codex login -``` - -```sh -npm install -g @anthropic-ai/claude-code -claude doctor -``` - -Native Windows harness installs do not automatically appear inside WSL2. Run `coven doctor` from WSL after installing harnesses. - -## First session - -```sh -cd "$HOME/code/project" -coven daemon start -coven daemon status -coven run codex "describe this repo" -coven sessions -``` - -## WSL2 versus native Windows - -Pick one environment for each working session: - -- Native Windows: install Coven and harness CLIs in PowerShell or Windows Terminal; use [Windows install](/install/windows). -- WSL2: install Coven and harness CLIs inside the Linux distro; use Linux paths and Linux `COVEN_HOME`. - -Do not point native Windows Coven and WSL2 Coven at the same state directory. - -## Source fallback - -If the npm native package is not available for your WSL distribution: - -```sh -git clone https://github.com/OpenCoven/coven.git -cd coven -cargo build --workspace -cargo run -p coven-cli -- doctor -``` - -See [Install from source](/install/from-source). +Install-method selection: **https://docs.opencoven.ai/docs/guide/install** diff --git a/docs/roadmaps/coven-automations-v1.mapping.json b/docs/roadmaps/coven-automations-v1.mapping.json new file mode 100644 index 00000000..ece26477 --- /dev/null +++ b/docs/roadmaps/coven-automations-v1.mapping.json @@ -0,0 +1,206 @@ +{ + "schema": "coven.automations-v1.tracker-mapping", + "schema_version": 1, + "description": "Machine-readable Bead <-> GitHub outcome mapping for the Coven Automations v1 program (OpenCoven/coven#859). GitHub owns public outcomes, acceptance gates, and durable evidence links. Beads (in the OpenCoven/coven-cave embedded Dolt database `cave`) owns the implementation dependency graph, execution ownership, and mirror inputs. This file is the reconciliation contract between the two; it is hand-reviewed, not hand-synced.", + "sync": { + "last_sync": "2026-08-30T15:05:00Z", + "source_branch": "agent/issue-859-p0-control-operationalize-coven-automations-v1", + "writer": "One canonical writer/process is designated for this setup; see docs/superpowers/plans/2026-08-30-issue-859-coven-automations-v1-tracker-operationalization.md (Decision D1). Persisted tracker changes land only through reviewed PRs.", + "canonical_bead_store": "OpenCoven/coven-cave embedded Dolt database `cave`; provisioning is routed through OpenCoven/coven-cave#5220. A competing Beads database must not be initialized in OpenCoven/coven (operational correction on OpenCoven/coven#859, 2026-08-30).", + "public_bead_export": "OpenCoven/coven-cave `.beads/issues.jsonl` is a public-scrubbed review export, never canonical state and never a hand-edited sync mechanism.", + "beads_tool_reference": "Beads 1.2.2, schema v53, as recorded in docs/superpowers/plans/2026-08-20-coven-v0.4.1-release-program.md; live schema/version verification is owned by OpenCoven/coven-cave#5220.", + "drift_check": "node docs/roadmaps/drift-check.mjs (add --beads-export to cross-check an export; --selftest to verify detection rules)", + "priority_policy": { + "P0": "Current v1 correctness, security, data-loss/duplicate-execution risk, authority violation, broken migration, or certification blocker.", + "P1": "Committed SDK/product/docs/ecosystem work required to make the certified core usable and operable.", + "P2": "Post-v1 expansion or research that must not silently enter the release critical path." + } + }, + "program": "https://github.com/OpenCoven/coven/issues/854", + "outcomes": [ + { + "slug": "program", + "role": "program", + "github": { + "repo": "OpenCoven/coven", + "issue": 854, + "url": "https://github.com/OpenCoven/coven/issues/854", + "title": "Program: Coven Automations v1 — reliable, identity-bound familiar routines", + "state": "open", + "priority": "P0", + "owner": "BunsDev" + }, + "bead": { + "label": "automations-v1/program", + "surface": "shared", + "title": "Coven Automations v1 program (GitHub OpenCoven/coven#854)", + "id": null, + "provisioning": "pending:OpenCoven/coven-cave#5220", + "disposition": "active:release-gate-ownership", + "gate": "Owns release gates and cross-repository rollup; final #854 release rollup must be generable from reconciled tracker state and exact evidence. Must not be used as a catch-all implementation task.", + "evidence_status": "none", + "evidence": [] + }, + "depends_on": [], + "depends_on_external": [], + "notes": "Priority is P0 program/control: it gates the release but must not absorb implementation work." + }, + { + "slug": "foundation", + "role": "p0-foundation", + "github": { + "repo": "OpenCoven/coven", + "issue": 816, + "url": "https://github.com/OpenCoven/coven/issues/816", + "title": "Native familiar automations: replace harness-owned schedules with durable Coven routines", + "state": "open", + "priority": "P0", + "owner": "BunsDev" + }, + "bead": { + "label": "automations-v1/foundation", + "surface": "shared", + "title": "Coven Automations v1 — native routine foundation (GitHub OpenCoven/coven#816)", + "id": null, + "provisioning": "pending:OpenCoven/coven-cave#5220 (no pre-existing bead found in the public-scrubbed export at sync time; reuse-if-present check owned by OpenCoven/coven-cave#5220)", + "disposition": "active:reconciling-landed-evidence", + "gate": "#816 evidence checklist: landed commit/PR series linked with an exact final foundation revision; clean-clone automation-test verification; pre-automations schema migration and rollback proof; daemon startup/tick/shutdown/restart proof on supported platforms; one scheduled and one manual run shown to traverse the same claim/ledger/runtime/delivery path; stale lease cannot block the next eligible occurrence indefinitely; failed output delivery cannot report success; every original acceptance criterion reconciled as implemented, deferred, or superseded; Cave ownership migration (OpenCoven/coven-cave#4990) linked with remaining compatibility facade documented.", + "evidence_status": "partial", + "evidence": [ + "https://github.com/OpenCoven/coven/pull/846 (merged 2026-08-28; routine definitions and control actions, part 1)", + "https://github.com/OpenCoven/coven/pull/847 (merged 2026-08-28; legacy import series, parts 5-8)", + "https://github.com/OpenCoven/coven/issues/816 (program-status section of the issue body, updated 2026-08-30: landed inventory and open evidence checklist)" + ] + }, + "depends_on": [], + "depends_on_external": [], + "notes": "Implementation materially landed on main 2026-08-28 (crates/coven-cli/src/automations/); the issue stays open for foundation reconciliation and exact evidence." + }, + { + "slug": "protocol", + "role": "p0-workstream", + "github": { + "repo": "OpenCoven/coven", + "issue": 855, + "url": "https://github.com/OpenCoven/coven/issues/855", + "title": "P0: Specify coven.automations.v1 schemas, state machines, idempotency, and changefeed", + "state": "open", + "priority": "P0", + "owner": "BunsDev" + }, + "bead": { + "label": "automations-v1/protocol", + "surface": "shared", + "title": "Coven Automations v1 — protocol schemas, state, idempotency, changefeed (GitHub OpenCoven/coven#855)", + "id": null, + "provisioning": "pending:OpenCoven/coven-cave#5220", + "disposition": "blocked:pending-foundation-reconciliation-and-bead-provisioning", + "gate": "coven.automations.v1 schemas, state machines, idempotency rules, and changefeed contract are specified, reviewed, and covered by tests; schema/conformance artifact revisions recorded as evidence.", + "evidence_status": "none", + "evidence": [] + }, + "depends_on": ["foundation"], + "depends_on_external": [], + "notes": "Feeds SDK read/types/changefeed (P1), SDK mutations/approvals (P1), Cave oversight/recovery (P1), and Psyche adapter (P1) once those outcomes are created." + }, + { + "slug": "scheduler", + "role": "p0-workstream", + "github": { + "repo": "OpenCoven/coven", + "issue": 856, + "url": "https://github.com/OpenCoven/coven/issues/856", + "title": "P0: Harden automation time, retries, cancellation, fencing, and crash recovery", + "state": "open", + "priority": "P0", + "owner": "BunsDev" + }, + "bead": { + "label": "automations-v1/scheduler", + "surface": "shared", + "title": "Coven Automations v1 — time, retry, cancel, fencing, recovery (GitHub OpenCoven/coven#856)", + "id": null, + "provisioning": "pending:OpenCoven/coven-cave#5220", + "disposition": "blocked:pending-foundation-reconciliation-and-bead-provisioning", + "gate": "Time, retry, cancellation, fencing, and crash-recovery behaviors verified by tests, including stale-lease recovery, misfire semantics, and cancellation without duplicate execution.", + "evidence_status": "none", + "evidence": [] + }, + "depends_on": ["foundation", "protocol"], + "depends_on_external": [], + "notes": "Depends on #816 and #855 where state/error semantics are required." + }, + { + "slug": "authority", + "role": "p0-workstream", + "github": { + "repo": "OpenCoven/coven", + "issue": 857, + "url": "https://github.com/OpenCoven/coven/issues/857", + "title": "P0: Bind automation runs to principal authority, familiar revisions, capabilities, approvals, and receipts", + "state": "open", + "priority": "P0", + "owner": "BunsDev" + }, + "bead": { + "label": "automations-v1/authority", + "surface": "shared", + "title": "Coven Automations v1 — principal/familiar/authority/approval/receipt binding (GitHub OpenCoven/coven#857)", + "id": null, + "provisioning": "pending:OpenCoven/coven-cave#5220", + "disposition": "blocked:pending-foundation-reconciliation-and-bead-provisioning", + "gate": "Runs are bound to principal authority, familiar revisions, capabilities, approvals, and receipts; negative tests prove authority bypass fails closed; receipts are tamper-evident and out of tracker scope.", + "evidence_status": "none", + "evidence": [] + }, + "depends_on": ["foundation", "protocol"], + "depends_on_external": [], + "notes": "Additionally depends on upstream Familiar Contract and Threads profile outcomes once created; no such cross-repository outcomes existed at sync time, so depends_on_external is empty and must be made explicit when they appear." + }, + { + "slug": "certification", + "role": "p0-workstream", + "github": { + "repo": "OpenCoven/coven", + "issue": 858, + "url": "https://github.com/OpenCoven/coven/issues/858", + "title": "P0: Build automations conformance, chaos, SLO, and operator diagnostics", + "state": "open", + "priority": "P0", + "owner": "BunsDev" + }, + "bead": { + "label": "automations-v1/certification", + "surface": "shared", + "title": "Coven Automations v1 — conformance, chaos, SLO, operator diagnostics (GitHub OpenCoven/coven#858)", + "id": null, + "provisioning": "pending:OpenCoven/coven-cave#5220", + "disposition": "blocked:pending-p0-workstreams-and-bead-provisioning", + "gate": "Conformance, chaos, SLO, and operator-diagnostics suites exist and run without ambient production credentials; the v1 release gate report is produced from them and linked here.", + "evidence_status": "none", + "evidence": [] + }, + "depends_on": ["protocol", "scheduler", "authority"], + "depends_on_external": [], + "notes": "Certification blocker for the v1 release gate owned by the program outcome." + } + ], + "cross_repository_children": [], + "cross_repository_children_policy": "One Bead per SDK, Cave, Psyche, docs, organization-canary, Familiar Contract, and Threads outcome created under OpenCoven/coven#854, mapped one-to-one in this file as each is created. They generally depend on the protocol outcome and, where authority-bearing, the authority outcome; exact dependencies must be explicit rather than inferred from the program parent.", + "p2_exclusions": [ + "Event triggers", + "Multi-host routing", + "Hosted execution", + "Broad external action adapters" + ], + "p2_exclusions_policy": "These remain post-v1 (P2) and must not be encoded as implicit P0 blockers anywhere in the graph.", + "invariants": [ + "Each GitHub outcome maps to exactly one Bead; each Bead maps to exactly one GitHub outcome.", + "Dependencies and P0/P1/P2 priorities in Beads match this file and the roadmap table generated from it.", + "A P0 Bead has one accountable owner, one canonical GitHub outcome, explicit dependencies, a current acceptance gate, an active or explicitly blocked disposition, evidence requirements, and no contradictory closed public mirror.", + "A Bead closes only when the corresponding GitHub acceptance criteria are satisfied or the outcome is explicitly cancelled/superseded with rationale.", + "Generated mirror bodies (including the generated mapping table in docs/roadmaps/coven-automations-v1.md) change only through the generator contract (node docs/roadmaps/drift-check.mjs --render).", + "Tracker data is never queried as production automation state; the Coven runtime owns definitions, occurrences, runs, attempts, leases, approvals, artifacts, events, and receipts.", + "Tracker output must not contain secrets, private prompts, terminal dumps, credentials, unrestricted personal paths, or sensitive identity/authority payloads." + ] +} diff --git a/docs/roadmaps/coven-automations-v1.md b/docs/roadmaps/coven-automations-v1.md new file mode 100644 index 00000000..ac7fa2ee --- /dev/null +++ b/docs/roadmaps/coven-automations-v1.md @@ -0,0 +1,181 @@ +--- +title: "Coven Automations v1 delivery roadmap" +summary: "Canonical Bead <-> GitHub outcome graph, priorities, dependencies, release gates, and drift controls for the Coven Automations v1 program (OpenCoven/coven#854, operationalized by OpenCoven/coven#859)." +read_when: + - Working on any Coven Automations v1 P0/P1 outcome + - Reconciling Beads state against GitHub outcomes + - Running or interpreting the tracker drift check +description: "Delivery roadmap for Coven Automations v1: tracker roles, ownership, the P0/P1/P2 table, the Bead-GitHub mapping, the dependency graph, release gates, and active blockers." +--- + +# Coven Automations v1 delivery roadmap + +_Last synchronized: 2026-08-30T15:05:00Z (see the sync metadata block below)_ + +> [!WARNING] +> **Generated content.** The mapping table in the marked block below is generated from +> `docs/roadmaps/coven-automations-v1.mapping.json` by +> `node docs/roadmaps/drift-check.mjs --render`. Edit the mapping, never the block. +> Mutable run status is deliberately **not** duplicated here: authoritative state lives +> in the trackers and is linked from this document. + +## Program + +**Coven Automations v1 — reliable, identity-bound familiar routines** +([OpenCoven/coven#854](https://github.com/OpenCoven/coven/issues/854)). +Tracker operationalization control: +[OpenCoven/coven#859](https://github.com/OpenCoven/coven/issues/859). +Parent of the initial P0 graph (#816, #855, #856, #857, #858). The program owns release +gates and cross-repository rollup and must not be used as a catch-all implementation task. + +## Canonical tracker roles + +| Tracker | Owns | Must never own | +| --- | --- | --- | +| **Beads** (canonical store: `OpenCoven/coven-cave` embedded Dolt database `cave`) | implementation dependency graph; task/quest assignment and active execution ownership; current priority and blocked state; branch/worktree linkage; interaction/delivery evidence references; generated GitHub mirror synchronization inputs | public acceptance criteria, cross-repository issue links, or any role as a runtime ledger | +| **GitHub** | public outcome and rationale; canonical acceptance criteria and release gates; cross-repository issue links; durable PR/release/conformance evidence links; design/governance decisions | mutable execution state that belongs to the Coven runtime | +| **Coven runtime** | automation definitions and revisions; occurrences, runs, attempts, leases, approvals, artifacts, events, receipts | — tracker data is never queried as production automation state | + +`.beads/issues.jsonl` in `OpenCoven/coven-cave` is a public-scrubbed review export — +never canonical state and never a hand-edited sync mechanism. Tracker changes land only +through reviewed PRs (see +[the #859 status/decision record](../superpowers/plans/2026-08-30-issue-859-coven-automations-v1-tracker-operationalization.md)). + +## Sync metadata + +- **Last synchronization:** 2026-08-30T15:05:00Z (UTC) +- **Source branch:** `agent/issue-859-p0-control-operationalize-coven-automations-v1` (based on upstream `main` at `1364cec`) +- **Machine-readable mapping:** [`coven-automations-v1.mapping.json`](./coven-automations-v1.mapping.json) (schema `coven.automations-v1.tracker-mapping`, version 1) +- **Drift check:** `node docs/roadmaps/drift-check.mjs` (add `--beads-export ` to cross-check an export; `--selftest` verifies detection rules) — runs locally and in CI without ambient production credentials +- **Beads tool reference:** Beads 1.2.2, schema v53, as recorded in + [the v0.4.1 release program record](../superpowers/plans/2026-08-20-coven-v0.4.1-release-program.md); + live schema/version verification and bead provisioning are owned by + [OpenCoven/coven-cave#5220](https://github.com/OpenCoven/coven-cave/issues/5220) +- **Writer:** exactly one canonical writer/process for this setup (Decision D1 in the + #859 status/decision record); concurrent independent migrations and direct writes from + unrelated worktrees are refused + +## P0 / P1 / P2 policy + +- **P0:** current v1 correctness, security, data-loss/duplicate-execution risk, authority violation, broken migration, or certification blocker. +- **P1:** committed SDK/product/docs/ecosystem work required to make the certified core usable and operable. +- **P2:** post-v1 expansion or research that must not silently enter the release critical path (event triggers, multi-host routing, hosted execution, broad external action adapters). + +Every P0 bead must have: one accountable owner; one canonical GitHub outcome; explicit +dependencies; a current acceptance gate; an active or explicitly blocked disposition; +evidence requirements; and no contradictory closed public mirror. + +## Outcome mapping + +The table below is the canonical Bead ↔ GitHub mapping (also available as JSON): + + +| Outcome | GitHub | Priority | Bead label | Bead ID | Dependencies | Disposition | +| --- | --- | --- | --- | --- | --- | --- | +| program | [OpenCoven/coven#854](https://github.com/OpenCoven/coven/issues/854) | P0 | `automations-v1/program` | (pending provisioning) | (none) | active:release-gate-ownership | +| foundation | [OpenCoven/coven#816](https://github.com/OpenCoven/coven/issues/816) | P0 | `automations-v1/foundation` | (pending provisioning) | (none) | active:reconciling-landed-evidence | +| authority | [OpenCoven/coven#857](https://github.com/OpenCoven/coven/issues/857) | P0 | `automations-v1/authority` | (pending provisioning) | foundation, protocol | blocked:pending-foundation-reconciliation-and-bead-provisioning | +| certification | [OpenCoven/coven#858](https://github.com/OpenCoven/coven/issues/858) | P0 | `automations-v1/certification` | (pending provisioning) | protocol, scheduler, authority | blocked:pending-p0-workstreams-and-bead-provisioning | +| protocol | [OpenCoven/coven#855](https://github.com/OpenCoven/coven/issues/855) | P0 | `automations-v1/protocol` | (pending provisioning) | foundation | blocked:pending-foundation-reconciliation-and-bead-provisioning | +| scheduler | [OpenCoven/coven#856](https://github.com/OpenCoven/coven/issues/856) | P0 | `automations-v1/scheduler` | (pending provisioning) | foundation, protocol | blocked:pending-foundation-reconciliation-and-bead-provisioning | + +_Cross-repository child outcomes: none created yet. One Bead per SDK, Cave, Psyche, docs, organization-canary, Familiar Contract, and Threads outcome under the program is mapped here one-to-one as each is created._ + + +Bead IDs are pending until provisioning lands through +[OpenCoven/coven-cave#5220](https://github.com/OpenCoven/coven-cave/issues/5220) — the +mapping records the contract (`surface:shared`, exact GitHub links, one-to-one outcomes) +and the drift check reports the gap (`W010`) until IDs are declared. + +## Dependency graph + +Minimum canonical P0 graph (from OpenCoven/coven#859): + +```text +#816 foundation + ├─ #855 protocol + ├─ #856 scheduler reliability + └─ #857 identity + authority + +#855 ─┬─> #856 + └─> #857 + +#855 + #856 + #857 -> #858 certification +#858 -> v1 release gate + +#855 -> SDK read/types/changefeed (P1) +#855 + #857 -> SDK mutations/approvals (P1) +#855 + #856 + #857 -> Cave oversight/recovery (P1) +#855 + #857 -> Psyche adapter (P1) +upstream Familiar/Threads profiles -> #857 (cross-repo, when created) +``` + +Exact dependencies are recorded per outcome in the mapping file +(`depends_on` slugs; `depends_on_external` for cross-repository outcomes). P1 ecosystem +beads must declare their exact dependencies rather than inheriting them from the broad +program parent. + +## Release gates + +1. **Foundation reconciled** — #816 evidence checklist complete (landed series linked, + clean-clone test verification, migration/rollback proof, daemon wiring proof, unified + manual/scheduled run path, stale-lease recovery, delivery-failure non-success, + compatibility facade reconciled with + [OpenCoven/coven-cave#4990](https://github.com/OpenCoven/coven-cave/issues/4990)). +2. **Protocol specified** — #855 schemas/state machines/idempotency/changefeed reviewed + and test-covered. +3. **Scheduler hardened** — #856 time/retry/cancel/fencing/recovery behaviors proven, + including no duplicate execution. +4. **Authority bound** — #857 principal/familiar/authority/approval/receipt binding with + fail-closed negative tests. +5. **Certification** — #858 conformance/chaos/SLO/operator-diagnostics suites run without + ambient production credentials; the v1 release gate report is generated from them. +6. **Program rollup** — the final #854 release rollup can be generated from reconciled + tracker state and exact evidence; no P2 work has leaked onto the critical path. + +## Active blockers + +- **Bead provisioning pending** — the Automations v1 delivery epic and its + `surface:shared` beads do not exist yet in Cave's canonical Beads/Dolt graph; + [OpenCoven/coven-cave#5220](https://github.com/OpenCoven/coven-cave/issues/5220) + owns creation, dependency verification (`bd dep list`, `bd ready --json`), bounded + `pnpm beads:sync` evidence, and before/after `refs/dolt/data` OIDs. No competing Beads + database may be initialized in `OpenCoven/coven`. +- **Foundation evidence reconciliation** — + [#816](https://github.com/OpenCoven/coven/issues/816) implementation landed on main + (2026-08-28) but its evidence checklist (clean-clone verification, migration proof, + daemon wiring proof, run-path proof) is still open. +- **Cross-repository profiles** — the Familiar Contract and Threads profile outcomes that + #857 must depend on do not exist yet; `depends_on_external` stays empty and explicit + until they are created. + +## Evidence and completion semantics + +A bead may close only when the corresponding GitHub acceptance criteria are satisfied or +the outcome is explicitly cancelled/superseded with rationale. Required evidence includes, +as applicable: PR/merge commit and exact source revision; exact verification +commands/results; schema/vector/conformance artifact revisions; migration/rollback proof; +cross-repository canaries; security/privacy/authority impact; release artifact digest and +certification report; remaining known limitations. A GitHub issue is not closed merely +because a bead has no active assignee or a partial implementation landed. + +## Drift detection + +```sh +# verify the committed mapping, the generated roadmap block, and (optionally) an export +node docs/roadmaps/drift-check.mjs +node docs/roadmaps/drift-check.mjs --beads-export .beads/issues.jsonl # coven-cave checkout +node docs/roadmaps/drift-check.mjs --strict # pending provisioning also fails +node docs/roadmaps/drift-check.mjs --selftest # verify detection rules +``` + +The check reports identifiers, statuses, priorities, links, and evidence references only, +requires no network or credentials, and flags: state disagreement (bead closed while the +GitHub outcome is open and vice versa), priority disagreement, P0 beads without an active +P0 outcome (owner/gate/disposition missing), outcomes without exactly one bead mapping, +unknown/ambiguous parent or dependency mappings, dependency cycles, completed work +lacking PR/test/release evidence, generated mirror bodies edited outside the generator +contract, and tracker output containing secrets or sensitive payloads. Severity policy: +`error` fails CI; `warning` (currently the pending-provisioning `W010`) is reported +without failing until provisioning is declared, after which the missing-mapping class +escalates to `error`. diff --git a/docs/roadmaps/drift-check.mjs b/docs/roadmaps/drift-check.mjs new file mode 100644 index 00000000..62a9ef09 --- /dev/null +++ b/docs/roadmaps/drift-check.mjs @@ -0,0 +1,727 @@ +#!/usr/bin/env node +// Drift check for the Coven Automations v1 tracker mapping (OpenCoven/coven#859). +// +// Verifies that the machine-readable Bead <-> GitHub mapping +// (docs/roadmaps/coven-automations-v1.mapping.json), the generated mapping table +// inside docs/roadmaps/coven-automations-v1.md, and an optional Beads public +// export (.beads/issues.jsonl from OpenCoven/coven-cave) agree. +// +// Design constraints (from OpenCoven/coven#859): +// - runnable locally and in CI without ambient production credentials; +// - no network access; the optional Beads export is a local file; +// - reports identifiers, statuses, priorities, links, and evidence references only; +// - tracker data is never treated as production automation state. +// +// Usage: +// node docs/roadmaps/drift-check.mjs # verify committed state (exit 1 on error-severity drift) +// node docs/roadmaps/drift-check.mjs --strict # pending-provisioning warnings also fail +// node docs/roadmaps/drift-check.mjs --beads-export PATH # cross-check a Beads issues.jsonl export +// node docs/roadmaps/drift-check.mjs --render # regenerate the roadmap mapping table in place +// node docs/roadmaps/drift-check.mjs --selftest # run built-in detection fixtures + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROADMAPS_DIR = path.resolve(SCRIPT_DIR); +const MAPPING_PATH = path.join(ROADMAPS_DIR, "coven-automations-v1.mapping.json"); +const ROADMAP_PATH = path.join(ROADMAPS_DIR, "coven-automations-v1.md"); + +const BLOCK_BEGIN = + ""; +const BLOCK_END = ""; + +const PRIORITIES = new Set(["P0", "P1", "P2"]); +const BEAD_PRIORITY_BY_NUMBER = { 0: "P0", 1: "P1", 2: "P2" }; + +// --------------------------------------------------------------------------- +// Sensitive-payload detection. Patterns are assembled from fragments so that +// this source file never itself contains a string matching the repo privacy +// guard or the detector below. +// --------------------------------------------------------------------------- + +function frag(...parts) { + return parts.join(""); +} + +const SENSITIVE_PATTERNS = [ + { + name: "coven_session_key", + pattern: new RegExp( + frag("agent:[A-Za-z0-9_-]+:(?:telegram|imessage|discord|whatsapp|", "signal|webchat):[a-z]+:\\S"), + ), + }, + { + name: "messenger_chat_id", + pattern: new RegExp( + frag("(?:telegram|imessage|discord|whatsapp|", "signal):(?:direct:)?\\d{6,}"), + ), + }, + { + name: "absolute_personal_path", + pattern: new RegExp(frag("/", "(?:Users|home)/[A-Za-z0-9._-]+/")), + }, + { + name: "runtime_internal_path", + pattern: new RegExp(frag("~/", "\\.(?:openclaw|coven)/(?:agents|workspaces|credentials|sessions)")), + }, + { + name: "phone_number", + pattern: new RegExp(frag("\\+[1-9]\\d{1,14}", "(?!\\d)")), + }, + { + name: "credential_bearing_url", + pattern: new RegExp(frag("ht", "tps?://\\S*(?:invite|handoff|ts\\.net)\\S*to", "ken\\S*")), + }, +]; + +function findSensitivePayloads(text) { + const hits = []; + for (const { name, pattern } of SENSITIVE_PATTERNS) { + const match = pattern.exec(text); + if (match !== null) { + hits.push({ rule: name, excerpt: "" }); + } + } + return hits; +} + +// --------------------------------------------------------------------------- +// Analysis core (pure; exercised by --selftest) +// --------------------------------------------------------------------------- + +function outcomeGithubRef(outcome) { + return `${outcome.github.repo}#${outcome.github.issue}`; +} + +function buildSlugIndex(mapping) { + const bySlug = new Map(); + for (const outcome of mapping.outcomes ?? []) { + bySlug.set(outcome.slug, outcome); + } + return bySlug; +} + +function findDependencyErrors(mapping) { + const findings = []; + const bySlug = buildSlugIndex(mapping); + const edges = new Map(); + + for (const outcome of mapping.outcomes ?? []) { + for (const dep of outcome.depends_on ?? []) { + if (!bySlug.has(dep)) { + findings.push({ + code: "E003", + severity: "error", + slug: outcome.slug, + message: `unknown dependency mapping: ${outcomeGithubRef(outcome)} depends on unknown slug '${dep}'`, + }); + } + } + edges.set(outcome.slug, [...(outcome.depends_on ?? [])]); + } + + const state = new Map(); + const stack = new Map(); + const visit = (slug) => { + if (state.get(slug) === "done") return true; + if (state.get(slug) === "visiting") { + findings.push({ + code: "E004", + severity: "error", + slug, + message: `dependency cycle involving '${slug}'`, + }); + return false; + } + state.set(slug, "visiting"); + for (const dep of edges.get(slug) ?? []) { + if (!visit(dep)) return false; + } + state.set(slug, "done"); + return true; + }; + for (const slug of edges.keys()) visit(slug); + + return findings; +} + +function findMappingErrors(mapping) { + const findings = []; + const seenRefs = new Map(); + const seenSlugs = new Set(); + const seenLabels = new Set(); + + for (const outcome of mapping.outcomes ?? []) { + const ref = outcomeGithubRef(outcome); + if (seenRefs.has(ref)) { + findings.push({ + code: "E001", + severity: "error", + slug: outcome.slug, + message: `GitHub outcome ${ref} maps to more than one bead ('${seenRefs.get(ref)}' and '${outcome.slug}')`, + }); + } else { + seenRefs.set(ref, outcome.slug); + } + + if (seenSlugs.has(outcome.slug)) { + findings.push({ + code: "E002", + severity: "error", + slug: outcome.slug, + message: `duplicate outcome slug '${outcome.slug}'`, + }); + } + seenSlugs.add(outcome.slug); + + const label = outcome.bead?.label; + if (label) { + if (seenLabels.has(label)) { + findings.push({ + code: "E002", + severity: "error", + slug: outcome.slug, + message: `duplicate bead label '${label}'`, + }); + } + seenLabels.add(label); + } + + const priority = outcome.github?.priority; + if (!PRIORITIES.has(priority)) { + findings.push({ + code: "E005", + severity: "error", + slug: outcome.slug, + message: `invalid or missing priority '${priority}' for ${ref} (expected one of ${[...PRIORITIES].join(", ")})`, + }); + } + + if (priority === "P0") { + const missing = []; + if (!outcome.github?.owner) missing.push("owner"); + if (!outcome.bead?.gate) missing.push("acceptance gate"); + if (!outcome.bead?.disposition) missing.push("disposition"); + if (missing.length > 0) { + findings.push({ + code: "E006", + severity: "error", + slug: outcome.slug, + message: `P0 outcome ${ref} is missing: ${missing.join(", ")}`, + }); + } + } + + const closedMirror = + outcome.github?.state === "closed" || + /^(complete|done|closed)/i.test(outcome.bead?.disposition ?? ""); + if (closedMirror && (outcome.bead?.evidence ?? []).length === 0) { + findings.push({ + code: "E007", + severity: "error", + slug: outcome.slug, + message: `completed work for ${ref} lacks PR/test/release evidence references`, + }); + } + } + + findings.push(...findDependencyErrors(mapping)); + return findings; +} + +function findPendingProvisioning(mapping) { + const findings = []; + for (const outcome of mapping.outcomes ?? []) { + if (outcome.bead?.id === null || outcome.bead?.id === undefined) { + const ref = outcomeGithubRef(outcome); + const provisioning = outcome.bead?.provisioning ?? "unrecorded"; + findings.push({ + code: "W010", + severity: "warning", + slug: outcome.slug, + message: `${ref} has no provisioned bead id yet (provisioning: ${provisioning})`, + }); + } + } + return findings; +} + +function renderMappingTable(mapping) { + const lines = [ + "| Outcome | GitHub | Priority | Bead label | Bead ID | Dependencies | Disposition |", + "| --- | --- | --- | --- | --- | --- | --- |", + ]; + const order = { program: 0, "p0-foundation": 1, "p0-workstream": 2 }; + const outcomes = [...(mapping.outcomes ?? [])].sort( + (a, b) => (order[a.role] ?? 9) - (order[b.role] ?? 9) || a.slug.localeCompare(b.slug), + ); + for (const outcome of outcomes) { + const ref = outcomeGithubRef(outcome); + const deps = (outcome.depends_on ?? []).join(", ") || "(none)"; + lines.push( + `| ${outcome.slug} | [${ref}](${outcome.github.url}) | ${outcome.github.priority} | \`${outcome.bead.label}\` | ${ + outcome.bead.id ?? "(pending provisioning)" + } | ${deps} | ${outcome.bead.disposition} |`, + ); + } + if ((mapping.cross_repository_children ?? []).length === 0) { + lines.push(""); + lines.push( + "_Cross-repository child outcomes: none created yet. One Bead per SDK, Cave, Psyche, docs, organization-canary, Familiar Contract, and Threads outcome under the program is mapped here one-to-one as each is created._", + ); + } + return lines.join("\n"); +} + +function extractGeneratedBlock(roadmapText) { + const begin = roadmapText.indexOf(BLOCK_BEGIN); + const end = roadmapText.indexOf(BLOCK_END); + if (begin === -1 || end === -1 || end < begin) return null; + const start = begin + BLOCK_BEGIN.length; + return roadmapText.slice(start, end).replace(/^\n/, "").replace(/\n\s*$/, "\n"); +} + +function findGeneratedBlockDrift(mapping, roadmapText) { + const committed = extractGeneratedBlock(roadmapText); + if (committed === null) { + return [ + { + code: "E008", + severity: "error", + slug: null, + message: "generated mapping table block missing from docs/roadmaps/coven-automations-v1.md", + }, + ]; + } + const expected = renderMappingTable(mapping); + if (committed.trimEnd() !== expected.trimEnd()) { + return [ + { + code: "E008", + severity: "error", + slug: null, + message: + "generated mapping table in docs/roadmaps/coven-automations-v1.md was edited outside the generator contract (run: node docs/roadmaps/drift-check.mjs --render)", + }, + ]; + } + return []; +} + +function beadPriorityLabel(priority) { + if (typeof priority === "number") return BEAD_PRIORITY_BY_NUMBER[priority] ?? `P${priority}`; + return String(priority ?? "unknown"); +} + +function collectBeadGithubRefs(bead) { + const haystack = [ + bead.external_ref, + bead.notes, + bead.design, + bead.acceptance_criteria, + ...(bead.comments ?? []).map((comment) => comment?.text ?? ""), + ] + .filter((value) => typeof value === "string") + .join("\n"); + const refs = new Set(); + for (const match of haystack.matchAll(/https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)/g)) { + refs.add(`${match[1]}#${match[2]}`); + } + for (const match of haystack.matchAll(/\b([\w.-]+\/[\w.-]+)#(\d+)\b/g)) { + refs.add(`${match[1]}#${match[2]}`); + } + return refs; +} + +function findExportDrift(mapping, exportText) { + const findings = []; + const beads = []; + for (const [index, line] of exportText.split("\n").entries()) { + const trimmed = line.trim(); + if (!trimmed) continue; + let bead; + try { + bead = JSON.parse(trimmed); + } catch { + findings.push({ + code: "E100", + severity: "error", + slug: null, + message: `beads export line ${index + 1} is not valid JSON`, + }); + continue; + } + beads.push({ line: index + 1, bead }); + } + + for (const { line, bead } of beads) { + for (const hit of findSensitivePayloads(JSON.stringify(bead))) { + findings.push({ + code: "E009", + severity: "error", + slug: bead.id ?? null, + message: `tracker output contains sensitive payload (rule: ${hit.rule}) at export line ${line}`, + }); + } + } + + const byRef = new Map(); + for (const outcome of mapping.outcomes ?? []) { + byRef.set(outcomeGithubRef(outcome), outcome); + } + + const beadRefs = new Map(); + for (const { line, bead } of beads) { + for (const ref of collectBeadGithubRefs(bead)) { + if (!byRef.has(ref)) continue; + if (!beadRefs.has(ref)) beadRefs.set(ref, []); + beadRefs.get(ref).push({ line, bead }); + } + } + + for (const [ref, outcome] of byRef) { + if (outcome.bead?.id !== null && outcome.bead?.id !== undefined) { + const linked = beadRefs.get(ref) ?? []; + if (linked.length === 0) { + findings.push({ + code: "E101", + severity: "error", + slug: outcome.slug, + message: `GitHub outcome ${ref} has no bead referencing it in the export (expected exactly one)`, + }); + } else if (linked.length > 1) { + findings.push({ + code: "E101", + severity: "error", + slug: outcome.slug, + message: `GitHub outcome ${ref} is referenced by ${linked.length} beads in the export (expected exactly one)`, + }); + } + } + } + + for (const [ref, entries] of beadRefs) { + const outcome = byRef.get(ref); + for (const { bead } of entries) { + const beadOpen = bead.status !== undefined && !["closed", "done"].includes(bead.status); + const githubOpen = outcome.github?.state === "open"; + if (beadOpen !== githubOpen) { + findings.push({ + code: "E102", + severity: "error", + slug: outcome.slug, + message: `state drift for ${ref}: bead '${bead.id}' status '${bead.status}' vs GitHub state '${outcome.github?.state}'`, + }); + } + const expectedPriority = outcome.github?.priority; + const actualPriority = beadPriorityLabel(bead.priority); + if (PRIORITIES.has(expectedPriority) && actualPriority !== expectedPriority) { + findings.push({ + code: "E103", + severity: "error", + slug: outcome.slug, + message: `priority drift for ${ref}: bead '${bead.id}' is ${actualPriority}, mapping says ${expectedPriority}`, + }); + } + const labels = bead.labels ?? []; + if (!labels.includes("surface:shared")) { + findings.push({ + code: "E104", + severity: "error", + slug: outcome.slug, + message: `bead '${bead.id}' mapped to ${ref} lacks the surface:shared label (has: ${ + labels.length > 0 ? labels.join(", ") : "(none)" + })`, + }); + } + } + } + + return findings; +} + +function analyze(mapping, roadmapText, exportText) { + const findings = [ + ...findMappingErrors(mapping), + ...findGeneratedBlockDrift(mapping, roadmapText), + ...findPendingProvisioning(mapping), + ]; + if (typeof roadmapText === "string") { + for (const hit of findSensitivePayloads(roadmapText)) { + findings.push({ + code: "E009", + severity: "error", + slug: null, + message: `roadmap artifact contains sensitive payload (rule: ${hit.rule})`, + }); + } + } + const mappingText = JSON.stringify(mapping, null, 2); + for (const hit of findSensitivePayloads(mappingText)) { + findings.push({ + code: "E009", + severity: "error", + slug: null, + message: `mapping file contains sensitive payload (rule: ${hit.rule})`, + }); + } + if (exportText !== undefined) { + findings.push(...findExportDrift(mapping, exportText)); + } + return findings; +} + +function printFindings(findings) { + if (findings.length === 0) { + console.log("drift-check: no findings"); + return; + } + for (const finding of findings) { + const scope = finding.slug ? ` [${finding.slug}]` : ""; + console.log(`${finding.code}${scope} ${finding.severity}: ${finding.message}`); + } +} + +function loadMapping() { + return JSON.parse(fs.readFileSync(MAPPING_PATH, "utf8")); +} + +function writeRenderedBlock(mapping) { + let roadmap = fs.readFileSync(ROADMAP_PATH, "utf8"); + const begin = roadmap.indexOf(BLOCK_BEGIN); + const end = roadmap.indexOf(BLOCK_END); + if (begin === -1 || end === -1 || end < begin) { + console.error("drift-check: generated block markers missing from roadmap; cannot render"); + process.exitCode = 2; + return false; + } + const replacement = `${BLOCK_BEGIN}\n${renderMappingTable(mapping)}\n${BLOCK_END}`; + roadmap = roadmap.slice(0, begin) + replacement + roadmap.slice(end + BLOCK_END.length); + fs.writeFileSync(ROADMAP_PATH, roadmap); + return true; +} + +function runSelftest() { + const failures = []; + const expectFinding = (findings, code, label) => { + if (!findings.some((finding) => finding.code === code)) { + failures.push(`selftest: expected ${code} (${label}) to be detected`); + } + }; + const clone = (value) => JSON.parse(JSON.stringify(value)); + + const baseMapping = loadMapping(); + const baseRoadmap = fs.readFileSync(ROADMAP_PATH, "utf8"); + const pristine = analyze(baseMapping, baseRoadmap, undefined); + const pristineErrors = pristine.filter((finding) => finding.severity === "error"); + if (pristineErrors.length > 0) { + failures.push(`selftest: committed state has error-severity findings: ${JSON.stringify(pristineErrors)}`); + } + if (!pristine.some((finding) => finding.code === "W010")) { + failures.push("selftest: expected W010 pending-provisioning warnings on the committed mapping"); + } + + const duplicateRef = clone(baseMapping); + duplicateRef.outcomes[1].github.issue = duplicateRef.outcomes[2].github.issue; + expectFinding(analyze(duplicateRef, baseRoadmap, undefined), "E001", "duplicate GitHub mapping"); + + const unknownDep = clone(baseMapping); + unknownDep.outcomes[2].depends_on.push("does-not-exist"); + expectFinding(analyze(unknownDep, baseRoadmap, undefined), "E003", "unknown dependency"); + + const cycle = clone(baseMapping); + cycle.outcomes[0].depends_on.push("certification"); + cycle.outcomes[5].depends_on.push("program"); + expectFinding(analyze(cycle, baseRoadmap, undefined), "E004", "dependency cycle"); + + const badPriority = clone(baseMapping); + badPriority.outcomes[2].github.priority = "P9"; + expectFinding(analyze(badPriority, baseRoadmap, undefined), "E005", "invalid priority"); + + const noOwner = clone(baseMapping); + noOwner.outcomes[2].github.owner = null; + expectFinding(analyze(noOwner, baseRoadmap, undefined), "E006", "P0 without owner"); + + const closedNoEvidence = clone(baseMapping); + closedNoEvidence.outcomes[2].github.state = "closed"; + expectFinding(analyze(closedNoEvidence, baseRoadmap, undefined), "E007", "closed without evidence"); + + const tamperedBlock = baseRoadmap.replace( + /\| program \| \[/, + "| program (edited outside the generator contract) | [", + ); + expectFinding(analyze(baseMapping, tamperedBlock, undefined), "E008", "mirror edit"); + expectFinding( + analyze(clone(baseMapping), "no markers here", undefined), + "E008", + "missing generated block", + ); + + const sensitiveMapping = clone(baseMapping); + sensitiveMapping.outcomes[0].notes = [ + "operator note: ", + frag("agent:", "demo", ":telegram:", "direct", ":SECRETVALUE"), + ].join(""); + expectFinding(analyze(sensitiveMapping, baseRoadmap, undefined), "E009", "sensitive payload in mapping"); + + const exportFixtures = [ + { + label: "closed bead vs open outcome (E102)", + line: JSON.stringify({ + _type: "issue", + id: "automations-v1.1", + title: "protocol", + status: "closed", + priority: 0, + labels: ["surface:shared"], + external_ref: "https://github.com/OpenCoven/coven/issues/855", + }), + codes: ["E102"], + }, + { + label: "priority drift (E103)", + line: JSON.stringify({ + _type: "issue", + id: "automations-v1.1", + title: "protocol", + status: "open", + priority: 1, + labels: ["surface:shared"], + external_ref: "https://github.com/OpenCoven/coven/issues/855", + }), + codes: ["E103"], + }, + { + label: "missing surface:shared label (E104)", + line: JSON.stringify({ + _type: "issue", + id: "automations-v1.1", + title: "protocol", + status: "open", + priority: 0, + labels: ["surface:api"], + external_ref: "https://github.com/OpenCoven/coven/issues/855", + }), + codes: ["E104"], + }, + { + label: "sensitive payload in export (E009)", + line: JSON.stringify({ + _type: "issue", + id: "automations-v1.9", + title: "leaky", + status: "open", + priority: 0, + labels: ["surface:shared"], + notes: frag("session ", "agent:x", ":telegram:bot:", "SECRET"), + }), + codes: ["E009"], + }, + ]; + + const emptyOutcomeMapping = clone(baseMapping); + for (const outcome of emptyOutcomeMapping.outcomes) { + outcome.bead.id = null; + outcome.bead.provisioning = "selftest"; + } + + for (const fixture of exportFixtures) { + const findings = analyze(emptyOutcomeMapping, baseRoadmap, fixture.line); + for (const code of fixture.codes) { + expectFinding(findings, code, fixture.label); + } + } + + const duplicateBeadExport = [ + JSON.stringify({ + _type: "issue", + id: "automations-v1.1", + status: "open", + priority: 0, + labels: ["surface:shared"], + external_ref: "https://github.com/OpenCoven/coven/issues/855", + }), + JSON.stringify({ + _type: "issue", + id: "automations-v1.2", + status: "open", + priority: 0, + labels: ["surface:shared"], + external_ref: "OpenCoven/coven#855", + }), + ].join("\n"); + const dupFindings = analyze(emptyOutcomeMapping, baseRoadmap, duplicateBeadExport); + if (!dupFindings.some((finding) => finding.code === "E101")) { + // Provisioning is pending, so E101 only fires for outcomes with declared ids. + const declared = clone(baseMapping); + declared.outcomes[2].bead.id = "automations-v1.1"; + const declaredFindings = analyze(declared, baseRoadmap, duplicateBeadExport); + expectFinding(declaredFindings, "E101", "duplicate bead references for one outcome"); + } + + if (failures.length > 0) { + console.error(failures.join("\n")); + return false; + } + console.log(`drift-check: selftest passed (${SENSITIVE_PATTERNS.length} sensitive-payload rules, 11 drift fixtures)`); + return true; +} + +function main(argv) { + const args = argv.slice(2); + if (args.includes("--selftest")) { + process.exitCode = runSelftest() ? 0 : 1; + return; + } + + let mapping; + try { + mapping = loadMapping(); + } catch (error) { + console.error(`drift-check: cannot parse mapping: ${error.message}`); + process.exitCode = 2; + return; + } + + if (args.includes("--render")) { + const ok = writeRenderedBlock(mapping); + if (ok) console.log("drift-check: regenerated the roadmap mapping table"); + return; + } + + let roadmapText; + try { + roadmapText = fs.readFileSync(ROADMAP_PATH, "utf8"); + } catch (error) { + console.error(`drift-check: cannot read roadmap: ${error.message}`); + process.exitCode = 2; + return; + } + + let exportText; + const exportIndex = args.indexOf("--beads-export"); + if (exportIndex !== -1) { + const exportPath = args[exportIndex + 1]; + if (!exportPath) { + console.error("drift-check: --beads-export requires a path"); + process.exitCode = 2; + return; + } + exportText = fs.readFileSync(path.resolve(exportPath), "utf8"); + } + + const findings = analyze(mapping, roadmapText, exportText); + printFindings(findings); + + const strict = args.includes("--strict"); + const hasErrors = findings.some((finding) => finding.severity === "error"); + const hasWarnings = findings.some((finding) => finding.severity === "warning"); + if (hasErrors || (strict && hasWarnings)) { + process.exitCode = 1; + } +} + +main(process.argv); diff --git a/docs/start/coven-tui.md b/docs/start/coven-tui.md index 2b7b125d..0899d262 100644 --- a/docs/start/coven-tui.md +++ b/docs/start/coven-tui.md @@ -139,7 +139,7 @@ Selecting a session and pressing `Enter` shows contextual actions. Rejoin, View | **Archive** | session is not `running` and not archived | Hide from the active list; events preserved. | | **Sacrifice** | session is not `running` | Before the typed `sacrifice` confirmation, a store retention check runs; an adopted or reserved row returns the canonical `AdoptionRetentionError` denial instead and is left untouched. Once confirmed, the final delete repeats the retention and liveness checks and only removes a still-non-running row, closing the race window rather than trusting that earlier read. | -The map between actions and CLI verbs is documented in [Session lifecycle](/SESSION-LIFECYCLE). +The map between actions and CLI verbs is documented in [Session lifecycle](../SESSION-LIFECYCLE.md). ## Legacy SSH and remote use @@ -167,6 +167,6 @@ These verbs produce stable, scriptable output and are the same ones the TUI ulti ## Related - [Get started with Coven](https://docs.opencoven.ai/docs/guide/getting-started) -- [Session lifecycle](/SESSION-LIFECYCLE) -- [CLI reference](/reference/cli) +- [Session lifecycle](../SESSION-LIFECYCLE.md) +- [CLI reference](https://docs.opencoven.ai/docs/cli) - [Troubleshooting](https://docs.opencoven.ai/docs/reference/troubleshooting) diff --git a/docs/start/doctor.md b/docs/start/doctor.md index 7c15252a..b0c82ba1 100644 --- a/docs/start/doctor.md +++ b/docs/start/doctor.md @@ -1,24 +1,9 @@ --- -summary: "What coven doctor checks and how to read its output." -read_when: - - Diagnosing a fresh install or a broken environment title: "Doctor" -description: "Run coven doctor after install. It reports local readiness without launching providers, contacting provider networks, or verifying provider authentication." +description: "Pointer to the canonical coven doctor guidance." --- -`coven doctor` is the first command to run after install. It reports: +Canonical `coven doctor` guidance: **https://docs.opencoven.ai/docs/cli/doctor** -- Whether `$COVEN_HOME` is writable. -- Whether the daemon socket can bind. -- Whether `codex`, `claude`, and `copilot` are on `PATH`. -- Whether the SQLite store is reachable. - -Doctor is offline and hermetic: it launches no provider CLI process, performs -no provider network request, does not inspect provider tokens or credential -stores, and does not verify authentication. External harness credential rows -are advisory even when an executable is present. - -Each finding includes a remediation hint. Missing or unverified harnesses point -to `coven setup`, where provider-owned login and optional verification require -explicit consent. Re-run `coven doctor` after fixing any line marked -`needs attention`. +The offline no-auth-verification boundary is also stated in the +source-adjacent reference [`../reference/cli-doctor.md`](../reference/cli-doctor.md). diff --git a/docs/start/first-session.md b/docs/start/first-session.md index 5eaaf896..beb084e3 100644 --- a/docs/start/first-session.md +++ b/docs/start/first-session.md @@ -1,24 +1,6 @@ --- -summary: "A guided walkthrough of running, attaching, and archiving one session." -read_when: - - You have Coven installed and want a concrete walkthrough title: "Your first session" -description: "Walkthrough: launch your first Codex session in Coven, attach to it, watch it complete, and archive the result through the rituals surface." +description: "Pointer to the canonical Coven getting-started guide." --- -This walkthrough launches a Codex session, attaches to it, watches it complete, and archives the result. - - - - `cd` into a repo. Coven will canonicalize this path as the **project root**. - - - `coven run codex "describe the layout of this repo"` - - - `coven sessions` opens the browser. Select the new session and choose **Rejoin**. - - - Press `a` in the session browser or run `coven archive `. - - +Canonical first-session walkthrough: **https://docs.opencoven.ai/docs/guide/getting-started** diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 601ff61e..8d8ec0f0 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -1,64 +1,10 @@ --- -summary: "Guided first run, project selection, harness verification, and ritual safety." -read_when: - - Walking a teammate through their first Coven setup title: "Onboarding" -description: "The coven onboarding flow: confirm COVEN_HOME, run doctor, validate a project root, pick a harness, and launch your first supervised session." +description: "Pointer to the canonical Coven getting-started guide." --- -Bare `coven`, `coven chat`, and `coven tui` open the managed Coven interactive -UI powered by `coven-code`. On the first interactive run, Coven offers to -install the pinned engine if it is missing. The onboarding flow: +Canonical onboarding guidance: **https://docs.opencoven.ai/docs/guide/getting-started** -1. Confirms `$COVEN_HOME` and creates it if missing. -2. Runs `coven doctor` and surfaces install hints. -3. Asks for the project root and validates it. -4. Picks a harness (`codex`, `claude`, or `copilot`) and checks that its CLI is - visible. -5. Suggests the safest first command. - -Doctor does not log in to a provider or verify provider access. Complete the -provider-owned login in the same terminal: - -```sh -coven setup codex -# or -coven setup claude -# or -coven setup copilot -``` - -These run `codex login`, `claude auth login`, and `copilot login` -respectively, after explicit consent. Use `coven setup all` to process all -three providers in order. - -Provider verification is optional and separately consented because it uses the -network and may incur provider usage or cost: - -```sh -coven setup codex --verify -# or, when login is already complete: -coven setup codex --verify-only -``` - -Setup requires a TTY and hands stdin, stdout, and stderr directly to the -provider. It does not capture provider output or emit machine JSON while the -provider runs. Release operators can write an atomic, redacted, fail-if-exists -report for one provider with `--report-json `. See -[`coven setup`](/reference/cli-setup) for the full privacy and report contract. - -The older in-process TUI is available only as the deprecated temporary -compatibility fallback `COVEN_LEGACY_TUI=1`; see [Coven TUI](/start/coven-tui) -for its legacy behavior. - -## First session - -After setup: - -```sh -coven doctor -coven daemon start -cd /path/to/project -coven run codex "explain this repo in 5 bullets" -coven sessions -``` +The provider-owned `coven setup` login, consent, and report contract is +described at **https://docs.opencoven.ai/docs/cli/setup**; its normative +reference remains in [`../reference/cli-setup.md`](../reference/cli-setup.md). diff --git a/docs/start/quickstart.md b/docs/start/quickstart.md index 86a5b296..66fb0b40 100644 --- a/docs/start/quickstart.md +++ b/docs/start/quickstart.md @@ -1,18 +1,6 @@ --- -summary: "The shortest copy-pasteable path to a live Coven session." -read_when: - - You already know what Coven is and want commands title: "Quickstart" -description: "Quickstart for Coven: install @opencoven/cli, run doctor, start the daemon, and summon your first Codex or Claude Code harness in a project root." +description: "Pointer to the canonical Coven getting-started guide." --- -```bash -npm install -g @opencoven/cli -coven doctor -coven daemon start -cd /path/to/your/project -coven run codex "fix the failing tests" -coven sessions -``` - -See [Getting started](/start/getting-started) for context. +Canonical getting-started guidance: **https://docs.opencoven.ai/docs/guide/getting-started** diff --git a/docs/start/showcase.md b/docs/start/showcase.md index df06b98b..98213f20 100644 --- a/docs/start/showcase.md +++ b/docs/start/showcase.md @@ -1,30 +1,7 @@ --- -summary: "Highlights of what Coven can do today and where it is heading." -read_when: - - Browsing for a one-page overview of Coven's value title: "Showcase" -description: "Showcase landing for Coven: a local-first runtime that supervises every coding-agent harness inside explicit project roots with auditable rituals." +description: "Pointer to the canonical Coven getting-started guide." --- -
-

Coven

-

A local-first runtime that supervises every coding-agent harness inside explicit project roots, with append-only events and rituals you can audit.

- -
- -## Highlights - - - - Three supported harnesses, more on the way through the adapter spec. - - - Session shape that CastCodes and advanced clients can replay. - - - Archive, summon, sacrifice — explicit verbs around destructive operations. - - +The canonical public journey starts at: +**https://docs.opencoven.ai/docs/guide/getting-started** diff --git a/docs/superpowers/plans/2026-08-30-coven-automations-v1-program-status.md b/docs/superpowers/plans/2026-08-30-coven-automations-v1-program-status.md new file mode 100644 index 00000000..d549dfcc --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-coven-automations-v1-program-status.md @@ -0,0 +1,93 @@ +# Coven Automations v1 Program Status Record — 2026-08-30 + +**Type:** status/decision record (verified facts; no task plan) +**Subject:** OpenCoven/coven issue #854 — Program: Coven Automations v1 +**Evidence snapshot:** upstream `main` at `1364cec9dbaf1e2aca2e4544dec0e1ce807d859c` (2026-08-30), inspected locally; GitHub state read via REST on 2026-08-30 ~15:03–15:20 UTC +**Deconfliction:** no open PR references #854 upstream, and no `agent/*854*` branch exists on the CompleteDotTech/coven fork (checked 2026-08-30 ~15:08 UTC) + +--- + +## Verdict + +**Coven Automations v1 is not satisfied on `main`. The local durable-scheduler foundation has landed and is independently confirmed in code; the v1 protocol, authority, conformance, SDK, and tracker-control work has not started.** The issue's own "foundation-ready, not yet v1-certified" assessment (final implementation assessment, 2026-08-30) matches the code; every P0 child issue (#855–#859) was opened on 2026-08-30 and is open with no landed work yet. + +- Definition of done status: **not met** (no P0 gate is implemented and evidenced end-to-end; no release candidate exists). +- #854 must remain open: this record closes nothing. The one delivered artifact is this record itself, which unblocks #816's evidence-closure item and #859's mapping task. + +## What exists on `main` today (evidence) + +The `coven#816` automations series landed 2026-08-28 (PR #846 merged 2026-08-28T14:22:30Z; PR #847 merged 2026-08-28T19:55:52Z; parts 6–8 commits dated 2026-08-28 arrived via consolidated merges `52c3d81` 2026-08-29 and `1364cec` 2026-08-30). All of the following was verified by direct inspection of `main` at `1364cec`: + +| Foundation element | Evidence on main | +| --- | --- | +| Versioned Coven-owned routine definitions | `crates/coven-cli/src/automations/definition.rs` (217 lines; introduced in `882fc83`, PR #846) | +| SQLite definition / occurrence / run records | `automations/store.rs:14` (`automation_definitions`), `automations/occurrences.rs:20` (`automation_occurrences`), `automations/runs.rs:13` (`automation_runs`) | +| RRULE-backed daily/weekly planning | `automations/rrule.rs` (180 lines, 8 unit tests), `automations/schedule.rs` (178 lines, 6 unit tests) | +| Unique occurrence fencing | `automations/occurrences.rs:31` — `UNIQUE(automation_id, scheduled_for)`; planning is idempotent | +| Claim leases and expiry/recovery | `automations/health.rs:23-24,79-106` (`lease_owner`, `lease_expires_at`, `stale_reason`) | +| Latest-only misfire, overlap refusal | defaults `misfire: "latest"`, `overlap: "forbid"` (`definition.rs:61-62,159-160`; enforced in `daemon_tick.rs:69-70`) | +| Daemon-side recurring tick + scheduled dispatch | `automations/daemon_tick.rs:33-51` (thread `coven-automations-scheduler`, fixed 60s cadence), wired at `crates/coven-cli/src/daemon.rs:4285`; shared launch path in `automations/runner.rs` (415 lines) | +| Familiar ID propagation, bounded logs, atomic delivery | `definition.rs:68` (`familiar_id: Option`), `runner.rs` | +| Health + run-history projections | `automations/health.rs` (203 lines), `automations/runs.rs` (307 lines) | +| Non-destructive paused legacy import | `automations/import_legacy.rs` (249 lines; reads `~/.codex/automations//automation.toml`, imports PAUSED, never modifies sources; PR #847 merged 2026-08-28T19:55:52Z) | +| `coven.automations.*` control actions | `crates/coven-cli/src/control_plane.rs:103-118` — capability domain `coven.automations` with 10 actions (`list`, `get`, `create`, `update`, `delete`, `tick`, `runs`, `run`, `import`, `health`); API-level tests in `crates/coven-cli/src/api.rs` (~lines 10639–10845) | + +Module size: `crates/coven-cli/src/automations/` is 11 files / 2,719 lines with 43 unit tests (per-file `#[test]` counts summed); exercised further by `crates/coven-cli/src/api.rs` integration tests. Cave-side ownership migration is reported in OpenCoven/coven-cave#4990 (per #816's body; not independently verified in this repo). + +### What is absent on `main` (verified) + +- No automations spec under `specs/` (12 spec directories, none for automations) and no `coven.automations.v1` schema, state-machine, typed-error, idempotency, or changefeed contract anywhere on main → #855. +- No automations documentation under `docs/` (grep for "automations" returns nothing) → coven-docs#76. +- No automation surface in the npm SDK `npm/coven/src` (no matches) → sdk#80. +- No Beads store in this repo (`.beads` absent) and no live-Dolt mutation yet → #859 (its 2026-08-30 comment states mutation of Cave's embedded-Dolt Beads graph is "not yet completed"). +- No conformance, chaos, load/SLO, or release-receipt gate → #858. +- No principal/capability/approval/receipt binding: `familiar_id` is an optional unversioned string validated only for length (`definition.rs:68,131-134`); `automation_runs` carries no authority evidence → #857 (+ familiar-contract#17, coven-threads#29, cross-repo). +- Scheduler cadence is a fixed wall-clock `thread::sleep(60s)` loop (`daemon_tick.rs:35-50`) with no virtual-time, DST-transition, clock-jump, or leader-fencing contract → #856. +- A routine remains a schedule + familiar-bound prompt (`definition.rs:4`), not a trigger/condition/authorized-action model. + +## Program issue family (REST state, 2026-08-30) + +| Issue | State | Created | Evidence note | +| --- | --- | --- | --- | +| #854 program | open | 2026-08-30T13:36:04Z | 1 comment: BunsDev operationalization checkpoint (14:06:20Z) | +| #816 foundation | open | 2026-08-24T15:32:52Z | body records foundation "materially landed"; closure blocked on an evidence checklist (updated 2026-08-30T13:54:46Z) | +| #855 protocol schemas | open | 2026-08-30T13:37:23Z | no activity | +| #856 time/fencing/crash hardening | open | 2026-08-30T13:38:31Z | no activity | +| #857 authority binding + receipts | open | 2026-08-30T13:39:54Z | 1 design comment (receipt replay resistance, 14:23:31Z) | +| #858 conformance/chaos/SLO | open | 2026-08-30T13:40:55Z | no activity | +| #859 Beads/GitHub mirrors | open | 2026-08-30T13:41:50Z | 1 comment routing the graph to Cave's embedded Dolt DB (`cave-hlv` epic; coven-cave#5219 roadmap PR; coven-cave#5220 seed/verification task) | + +Cross-repo outcomes cited by the #854 checkpoint comment (not independently verified in this sweep): P0 — OpenCoven/familiar-contract#17, OpenCoven/coven-threads#29; P1 — OpenCoven/sdk#80, OpenCoven/coven-cave#5217, OpenCoven/psyche#18, OpenCoven/coven-docs#76, OpenCoven/.github#2. Zero PRs are open upstream at snapshot time; no PR implements any of #855–#859 yet. + +## Verdict against the issue's gates + +- **Gate A (durable local scheduler):** partially met — deterministic planning, unique occurrence fencing, bounded leases, latest-only misfire, overlap refusal, 60s daemon tick, and 43 module unit tests exist; DST/virtual-time/clock-jump/restart-convergence certification does not (#856 open). +- **Gate B (identity and authority):** not met — optional string `familiar_id` only; no principal authorization, capability grants, approval path, or exercised-authority receipts on any run record (#857, familiar-contract#17, coven-threads#29). +- **Gate C (public contract):** not met — the wire contract lives in Rust structs (`definition.rs`) with no independent versioned schemas or golden vectors; the SDK has no automation surface (#855, sdk#80). +- **Gate D (operations):** partial — health snapshot and run-history projections exist and are CLI/API-observable; chaos/restart certification, load/SLO evidence, alerts/retention/redaction exercise, and a machine-readable release receipt do not exist (#858 open). +- **Tracker (Beads/GitHub graph):** not started — the canonical graph lives in Cave's Dolt database; the seeding/verification task (coven-cave#5220) has not been executed (#859 open). + +## Critical path + +The #854 checkpoint comment (2026-08-30T14:06:20Z) fixes the engineering sequence, which matches the issue's Beads dependency rules and this record's code findings: + +1. coven-cave#5220 / #859 — seed and verify the Automations v1 delivery epic in Cave's embedded-Dolt Beads graph (first executable action). +2. #816 — attach landed-series evidence (PRs #846/#847 + parts 6–8 commits, clean-clone test run, migration and daemon/restart verification), then close #816 as the landed foundation. +3. #855 — versioned `coven.automations.v1` schemas, state machines, idempotency, typed errors, changefeed. +4. In parallel: #856 (deterministic time, DST, retries, cancellation, fencing, crash recovery) + familiar-contract#17 + coven-threads#29. +5. #857 — dispatch-time principal/familiar/authority/runtime/approval binding and receipts. +6. #858 — conformance, chaos, security/privacy, load/SLO, operator diagnostics. +7. P1 consumption — sdk#80, coven-cave#5217 (Cave oversight), psyche#18, coven-docs#76, .github#2. +8. Exact-release go/no-go packet (release receipt, certification). + +## Decision + +- Do **not** close #854, #816, #855–#859. #854 is correctly decomposed; its verdict ("foundation-ready, not yet v1-certified") is independently confirmed by the code inspection above. +- Unattended external side effects stay out of scope until #857 and #858 pass at exact immutable artifacts (per the #854 safety gate). +- Next executable actions: coven-cave#5220 (live Beads seeding) and #816 evidence closure; both precede any #855 contract work. + +## Sources + +- Code: `crates/coven-cli/src/automations/` at `1364cec` (files, tests, and line refs as cited above); `crates/coven-cli/src/control_plane.rs:103-116`; `crates/coven-cli/src/daemon.rs:4285`. +- History: commits `882fc83` (part 1, PR #846, merged 2026-08-28T14:22:30Z), `39b8618` (part 5, PR #847, merged 2026-08-28T19:55:52Z), `bd3b47d`/`a4a71af`/`1de50a8` (parts 6–8, 2026-08-28), `52c3d81` (2026-08-29), `1364cec` (2026-08-30). +- Issues (all read 2026-08-30 via REST): OpenCoven/coven#854, #816, #855, #856, #857, #858, #859; cross-repo train per the #854 checkpoint comment (familiar-contract#17, coven-threads#29, sdk#80, coven-cave#5217/#5219/#5220, psyche#18, coven-docs#76, .github#2). diff --git a/docs/superpowers/plans/2026-08-30-issue-670-docs-program-status.md b/docs/superpowers/plans/2026-08-30-issue-670-docs-program-status.md new file mode 100644 index 00000000..8d7846be --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-issue-670-docs-program-status.md @@ -0,0 +1,181 @@ +# Issue #670 Docs Program — Status and Decision Record (2026-08-30) + +**Scope:** Verified status of +[OpenCoven/coven#670](https://github.com/OpenCoven/coven/issues/670) +(docs: progressive disclosure, single-source docs, and E2E certification) +against `main` at commit `1364cec9dbaf1e2aca2e4544dec0e1ce807d859c` +(2026-08-30T06:31:53-05:00), its child issues, and the `OpenCoven/coven-docs` +site repo. Facts and evidence only; no code changes are proposed by this +record. + +**Verdict in one line:** The executable-help half of #670 and the packaged E2E +lane are on `main`; the canonical journey and docs CI live and run in +`OpenCoven/coven-docs`; the single-source cleanup of this repository +(README shrink, remaining local public pages, CI ownership enforcement) and the +#779 certification matrix are the remaining program work. + +--- + +## Program structure (evidence) + +Maintainer comment on #670 (BunsDev, 2026-08-20T23:16:48Z) tracks execution as +Beads epic `coven-v8l` with children: + +| Child | Beads | Title | State (2026-08-30) | +| --- | --- | --- | --- | +| #774 | `coven-v8l.1` | feat(cli): add progressive help disclosure and help contract | open — implementation merged on main | +| #775 | `coven-v8l.2` | docs: reshape canonical first-session and troubleshooting journey | open — canonical journey live in coven-docs | +| #776 | `coven-v8l.3` | docs: remove duplicate local public documentation | open — partially done, largest remaining slice | +| #777 | `coven-v8l.4` | test(cli): add packaged first-session E2E journey | **closed** — merged via #835 | +| #778 | `coven-v8l.5` | ci(docs): add canonical docs build, link, and browser journey | open — workflow live in coven-docs | +| #779 | `coven-v8l.6` | test: certify Coven end-to-end from packaged artifact through recovery and release evidence | open — matrix not yet evidenced | + +GitHub Project 3 / Project 8 rollup state is not verifiable here: the task +constraints forbid GraphQL, and the REST v3 API exposes no project state for +this token. Beads state is cited only from the maintainer comment above. + +## What exists on `main` today (with evidence) + +### #774 — progressive help: merged, issue still open + +PR #834 `feat(cli): add progressive public help` +(`OpenCoven:finish/670-progressive-help` → `main`) merged 2026-08-25T12:08:08Z +as commit `3724f26455a9488e80eaa9d8379ee7833f69e52d` (1828 insertions, +15 deletions across 8 files). Delivered on main: + +- `crates/coven-cli/src/help.rs` — default top-level help lists exactly eight + commands (doctor, setup, run, sessions, attach, daemon, status, help) via + `TOP_LEVEL_AFTER_HELP`; `coven help --all` renders six public groups + (`HELP_GROUPS`, 39 commands total); `coven help --all --json` emits the + machine-readable contract. +- Coverage enforcement: help.rs fails when any visible command lacks public + metadata (`public help metadata is missing visible command(s)`, + `public command ... is missing an about string`). +- `crates/coven-cli/tests/help_disclosure.rs` (464 lines) asserts the curated + top-level command list and that internal commands (`chat`, `config`) are not + listed. +- `scripts/export-cli-help-contract.mjs` (279 lines) and + `scripts/export-cli-help-contract-test.mjs` (323 lines) export a deterministic + JSON help contract (schemaVersion 1) and reject duplicate commands, internal + leakage (`process-supervisor`, `serve`), non-canonical docs URLs (must be + stable `https://docs.opencoven.ai/docs/...`), ANSI escapes, and + machine-specific paths. +- `scripts/test-cli-prepublish.mjs` chains `onboarding-docs-test.mjs`, + `cli-docs-test.mjs`, and the contract test into the npm prepublish gate. + +Local verification for this record (no cargo/python in the executing +environment): `node --test scripts/cli-docs-test.mjs` 7/7 pass; +`node --test scripts/onboarding-docs-test.mjs` 16/16 pass; `git diff --check` +clean. Rust-side tests (`cargo test`) were not run locally (no toolchain) and +are covered by CI. + +### #777 — packaged first-session E2E: merged and closed + +PR #835 `test(cli): add packaged first-session E2E journey` +(`OpenCoven:finish/777-packaged-journey` → `main`) merged 2026-08-25T13:47:31Z +(2686 insertions, 177 deletions, 13 files); issue #777 closed 2026-08-25. Delivered on main: + +- `scripts/user-journey-e2e.mjs` — hermetic journey through the installed npm + wrapper with isolated `COVEN_HOME`: bare-runner `coven doctor` fails closed + with first-run guidance, deterministic fake harness install, daemon + lifecycle, a real packaged `coven run codex ...` turn, sessions/show/events/ + log inspection, archive/summon/sacrifice, bounded `--cwd` rejection, and + daemon cleanup/shutdown. Curated top-level surface re-checked in the + packaged artifact (`CURATED_COMMANDS`). +- CI lanes in `.github/workflows/ci.yml`: `npm-onboarding-pr` (PRs touching + npm packaging; linux-x64 + windows) and `npm-onboarding-main` (push; + macos-26 arm64, macos-15-intel x64, ubuntu x64, windows) both run + `node scripts/test-cli-prepublish.mjs --skip-build --skip-secrets-scan`, + which drives `runPackagedUserJourney`. This is the issue's "required PR + lane"; the push matrix covers macOS arm64/x64, Linux x64, Windows x64. + +### #775 / #778 — canonical journey and docs CI: live in `OpenCoven/coven-docs` + +`OpenCoven/coven-docs` (Fumadocs + MDX, default branch `main`, last push +2026-08-26T10:01:53Z): + +- `content/docs/guide/getting-started.mdx` follows the issue's user story: + preflight (doctor) → connect a harness → run a first session → inspect the + result → lifecycle actions → `Continue` next steps pointing at + `/docs/cli/setup`, `/docs/guide/install`, `/docs/guide/concepts`, + `/docs/cli/sessions`, and `/docs/reference/troubleshooting` (recovery route). +- `scripts/check-cli-docs.mjs` enforces canonical coverage: required pages + (cli index, install, install-debugging, interactive, doctor, setup, daemon, + run, sessions, observe, hub-scheduler, engine-auth, repo-workflow, + patch-openclaw, pc, uninstall) and required guide pages (getting-started, + install, platforms, deployments), plus per-page required command mentions. +- `.github/workflows/docs.yml` runs on every PR and push to `main`: + `pnpm check:source-drift` freshness gate, Chrome install, `pnpm verify` + (= `check` [typecheck, content/link/anchor guards, api-runner tests] + + Next build) + `test:smoke`, a generated-tree cleanliness gate, and evidence + artifact upload. `scripts/smoke-docs.mjs` drives real Chromium routes: `/`, + `/docs`, `/docs/guide/getting-started`, `/docs/cli/setup`, + `/docs/guide/ecosystem`, `/docs/reference/api`, with screenshots. + Workflows present: `docs.yml`, `docs-source-drift.yml`, `docs-live.yml`. +- Recent coven-docs merges: #56 (2026-08-24, "refactor: certify and redesign + the Coven documentation release surface"), #72 (2026-08-24, production + sentinel fix), #74 (2026-08-26, "docs: reconcile Coven CLI source drift"). + +### Historical context (per the issue text) + +The 2026-08-07 three-wave design +(`docs/superpowers/specs/2026-08-07-final-documentation-single-source-audit-design.md`) +and its wave plans (`docs/superpowers/plans/2026-08-07-documentation-wave-{a,b,c}-*.md`, +96 unchecked tasks total) remain the historical audit plan. Parts were +absorbed earlier: commit `6c267f871f45caa1e66e8d91c2a26573b158b347` +(`docs: establish canonical public documentation links (#668)`, 2026-08-07) +removed 1958 lines across 97 files and left `docs/GETTING-STARTED.md`, +`docs/CONCEPTS.md`, and `docs/TROUBLESHOOTING.md` as 8-line canonical +pointers. The wave-plan checkboxes were never updated in-file and their +remaining substance (README, residual local pages, enforcement) is still open +work under #776. + +## Verdict against #670 acceptance criteria + +| # | Acceptance criterion | Verdict | Evidence | +| --- | --- | --- | --- | +| 1 | Default top-level help exposes at most eight core commands plus help | **Met** | `help.rs` `TOP_LEVEL_AFTER_HELP` (7 core + help); `help_disclosure.rs` asserts the exact curated list | +| 2 | `coven help --all` includes every public command without internal commands | **Met** | `HELP_GROUPS` (6 groups, 39 commands); missing-metadata hard error in help.rs; contract test rejects `process-supervisor`/`serve` leakage | +| 3 | Canonical getting started covers install, readiness, one recorded run, inspection before advanced next steps | **Met (coven-docs)** | `content/docs/guide/getting-started.mdx` structure 1–4 + Continue | +| 4 | Every core command maps to a stable canonical documentation route | **Met** | `HELP_GROUPS` docs paths; contract test requires stable `docs.opencoven.ai` URLs; coven-docs `check-cli-docs.mjs` enforces the pages exist | +| 5 | Public-doc directories contain only approved pointers or source-adjacent exceptions, enforced by CI | **Not met** | No ownership-enforcement guard exists in `scripts/` or `.github/workflows/ci.yml`; `cli-docs-test.mjs` checks only specific routes. Only 3 local pages are pointers today | +| 6 | README is a concise landing page rather than a second manual | **Not met** | `README.md` on main is 867 lines / 47,904 bytes with Commands Reference, Local API, Architecture, Repository Structure, Configuration, FAQ, and Troubleshooting sections | +| 7 | Packaged CLI E2E proves doctor, daemon, run, inspect, lifecycle actions, failure guidance, shutdown | **Met** | `scripts/user-journey-e2e.mjs` + `test-cli-prepublish.mjs`; CI lanes `npm-onboarding-pr`/`npm-onboarding-main` (#835) | +| 8 | `coven-docs` PR CI builds, validates links, and browser-tests the primary journey | **Met (coven-docs)** | `docs.yml` verify + smoke (Chromium) on PR and push | +| 9 | Delivery tracked in Beads and Project 3; certification evidence under Project 8 | **Partially verified** | Beads IDs confirmed via maintainer comment (2026-08-20); Project state unverifiable under REST-only constraints | + +## What remains + +1. **#776 (critical path, this repo).** Shrink `README.md` to a landing page; + finish the local public-page reduction that #668 started (the three + 2026-08-07 wave plans hold the residual page list); add the CI guard that + enforces the "only approved pointers or source-adjacent exceptions" rule + from `docs/DOCS-MAINTENANCE.md`. Its recorded blockers (`coven-v8l.1`, + `coven-v8l.2`) are satisfied on the ground — help (#834) and the canonical + journey (coven-docs) have landed. +2. **#779 certification.** Execute and evidence the certification matrix + (hermetic lane evidence exists via #777; real providers, remote hosts, + destructive recovery, interactive surfaces, and deployed-site checks + remain). `scripts/certify-release.sh` (PR #851 merged 2026-08-28T21:48:01Z) + is the release-certification helper; #805 (release authorization), #803, + #804, #807, #808 feed it. +3. **Issue close-out.** #774, #775, and #778 are still open although their + implementation surfaces are merged/live; they need maintainer verification + and closure (Beads `coven-v8l.1`, `.2`, `.5`), then #670 and the + `coven-v8l` epic can close. + +## Critical path + +#776 cleanup + ownership-enforcement CI → close-out of #774/#775/#778 → +#779 certification evidence (consumes #777 + deployed-site checks, gated by +#805) → #670 umbrella completion. + +## Record method and limitations + +- Investigated 2026-08-30 via GitHub REST only (`gh api`); GraphQL was not + used, so GitHub Project rollup state is unverified. +- Beads state cited from the maintainer comment on #670; bead records were + not read or modified. +- No Rust toolchain or Python in the executing environment; Rust/Python CI + checks were not run locally. The two Node docs guards were run locally and + pass; both are also run by CI's policy/prepublish lanes. diff --git a/docs/superpowers/plans/2026-08-30-issue-807-shipped-reliability-scorecard.md b/docs/superpowers/plans/2026-08-30-issue-807-shipped-reliability-scorecard.md new file mode 100644 index 00000000..7e0613d4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-issue-807-shipped-reliability-scorecard.md @@ -0,0 +1,172 @@ +# Issue #807 Status Record — Shipped Reliability, Recovery, and Usefulness Scorecard + +> **For agentic workers:** This is a dated status/decision record, not an +> implementation plan. It documents what exists on `main` relative to +> [OpenCoven/coven#807](https://github.com/OpenCoven/coven/issues/807) at the +> recorded base SHA. Facts and evidence links only; it proposes no SLOs and +> quotes no measurements. + +**Date:** 2026-08-30 +**Issue:** [#807 — P1: establish Coven shipped reliability, recovery, and usefulness scorecard](https://github.com/OpenCoven/coven/issues/807) (open; created 2026-08-24; 0 comments; no labels) +**Base inspected:** `main` @ [`1364cec`](https://github.com/OpenCoven/coven/commit/1364cec9) ("chore: preserve consolidated branch ancestry", 2026-08-30 06:31 -0500) +**Related issues at inspection:** [#805](https://github.com/OpenCoven/coven/issues/805) (P0 exact-commit release governance — **open**), [#779](https://github.com/OpenCoven/coven/issues/779) (installed-artifact E2E certification — **open**) +**Method:** REST-only GitHub API (search + pulls + branches for deconfliction; issues, releases, release-by-tag lookups) plus local inspection of the clone at the base SHA. No PR existed for #807 and no fork branch referenced it at inspection time (search/issues, `pulls?state=open`, `branches?per_page=100`, 2026-08-30). No benchmarks were executed for this record; every statement below is an existence/capability statement about code and docs on `main`, not a measurement. + +--- + +## What exists on main today + +### 1. Non-gating benchmark/trend corpus (the "strong test corpus" the issue refers to) + +| Instrument | What it covers | Evidence | +| --- | --- | --- | +| `scripts/benchmark-cli.mjs` | Command startup, cold daemon start-to-health, session-listing, event-tail, harness-first-output timings; per-run min/median/p95/max; disposable `COVEN_HOME`s, fake Codex fixture, env redaction. Last touched 2026-08-11. | [`scripts/benchmark-cli.mjs`](https://github.com/OpenCoven/coven/blob/1364cec9/scripts/benchmark-cli.mjs); [README §"CLI performance baselines"](https://github.com/OpenCoven/coven/blob/1364cec9/README.md) (lines ~702–721) | +| `scripts/benchmark-chaos.mjs` (report schema v3) | 1/8/32 concurrent deterministic sessions; launch-to-first-output percentiles; throughput; cancellation-to-terminal latency; SQLite file growth; writer connection/transaction deltas; sampled writer backlog; sampled daemon RSS via `coven pc top --json` (no process names/command lines retained); deterministic equivalents for free-disk watermark, SQLite lock/retry, persisted-session crash recovery. Last touched 2026-08-08. | [README §"Concurrent runtime baseline"](https://github.com/OpenCoven/coven/blob/1364cec9/README.md) (lines ~723–756); [`scripts/benchmark-chaos.mjs`](https://github.com/OpenCoven/coven/blob/1364cec9/scripts/benchmark-chaos.mjs) | +| CI collection (non-gating) | Both collectors run in the `performance-baseline` CI job with `continue-on-error: true` (ci.yml lines 248, 281); artifacts uploaded, no wall-clock gate. The deterministic fixture tests (`benchmark-cli.test.mjs`, `benchmark-chaos.test.mjs`) do gate. | [`.github/workflows/ci.yml`](https://github.com/OpenCoven/coven/blob/1364cec9/.github/workflows/ci.yml) | +| Deterministic Rust metric test | Ignored test `benchmark_schedule_metrics_emit_json` prints deterministic TUI poll/draw counters. | README lines ~709, 716–717 | + +The README already states the separation the issue demands: outputs are +"trend data", benchmark p50/p95/p99 "do not replace that product-level +timeout" (the Cave managed-start 8-second deadline), and chaos coverage +entries "remain separate from trend measurements, so a timing artifact cannot +be mistaken for a passing failure-path test". + +**Relevance to #807 and gap:** these are **benchmark condition/input** +instruments. They are per-run JSON artifacts — no history is retained on +`main`, no multi-run trend table exists, and the default sample count is 3 +iterations (`--iterations 3`), which the issue's non-goals correctly disallow +quoting as product statistics. + +### 2. Health/readiness and recovery surfaces (instrumentation for journey rows) + +- `GET /api/v1/health` includes a `storage` object: SQLite/WAL sizes, free + space, oldest retained event, prune/checkpoint ages, writer backlog; + `storage.status` becomes `critical` with `maintenanceBlocked: true` below + 256 MiB free. Recovery logging rotates at 4 MiB with three archives. + [docs/daemon/health.md](https://github.com/OpenCoven/coven/blob/1364cec9/docs/daemon/health.md). +- `coven doctor` gives first-run readiness guidance with no harness on PATH + ([docs/reference/cli-doctor.md](https://github.com/OpenCoven/coven/blob/1364cec9/docs/reference/cli-doctor.md)); + `coven setup --verify-only --report-json` emits a **redacted + certification report carrying only harness, cli_version, platform, + candidate_commit, duration, exit_class, completed** (v0.4.1 release notes). +- Recovery/operations docs and landed plans: orphan recovery, session handoff + and cursor recovery, upgrades, diagnostics under + [docs/daemon/](https://github.com/OpenCoven/coven/tree/1364cec9/docs/daemon); + plans `2026-08-01-incomplete-work-recovery`, `2026-08-06-session-handoff-cursor`, + `2026-08-05-output-truncation-markers`, `2026-08-09-pty-sigterm-load-resilience`, + `2026-08-03-mobile-pairing-retry-recovery`, + `2026-08-03-universal-runtime-capability-recovery` (all in + [docs/superpowers/plans/](https://github.com/OpenCoven/coven/tree/1364cec9/docs/superpowers/plans)). + +### 3. Release certification and structured-receipt pattern + +- [`scripts/certify-release.sh`](https://github.com/OpenCoven/coven/blob/1364cec9/scripts/certify-release.sh) + (added 2026-08-29, commit [`e0ad4b0`](https://github.com/OpenCoven/coven/commit/e0ad4b0)): + three-harness release certification packet; runs `coven setup + --verify-only --report-json` against real accounts and verifies + every report certifies the tagged commit. Operator-run local step (needs a + TTY; costs real provider turns). +- The [v0.4.1 release program plan](https://github.com/OpenCoven/coven/blob/1364cec9/docs/superpowers/plans/2026-08-20-coven-v0.4.1-release-program.md) + (2026-08-20) specifies a `release-evidence/v0.4.1-certification.json` + structured receipt bound to a frozen SHA. **`release-evidence/` is not + committed on `main` at the base SHA** — the receipt exists as a program + pattern, not a repo artifact. +- Published releases observed via REST (2026-08-30): v0.4.1 + (2026-08-28T15:29:09Z; 4 platform tarballs + `SHA256SUMS`), v0.4.0/v0.3.x + (2026-08-24), v0.2.5 (2026-08-09). The v0.4.1 release body documents the + redacted `--report-json` certification contract. + +### 4. Packaged-artifact journey evidence + +- [`scripts/user-journey-e2e.mjs`](https://github.com/OpenCoven/coven/blob/1364cec9/scripts/user-journey-e2e.mjs) + (updated 2026-08-28, commit [`8ae39cd`](https://github.com/OpenCoven/coven/commit/8ae39cd)): + hermetic npm-package journey — help contract, first-run `doctor` guidance, + fake Codex + engine fixture, daemon lifecycle, a real packaged `coven run` + turn, sessions/show/events/log inspection, archive/summon/sacrifice, bounded + `--cwd` rejection, daemon cleanup. Binary **pass/fail** journey coverage — + it does not yet emit stage-level timing/failure-stage observations. +- [`scripts/release-stress.mjs`](https://github.com/OpenCoven/coven/blob/1364cec9/scripts/release-stress.mjs) + + [`release-stress.yml`](https://github.com/OpenCoven/coven/blob/1364cec9/.github/workflows/release-stress.yml) + (added 2026-08-24 — the same day #807 was filed): bounded reliability stress + workflow, `workflow_dispatch`, OS matrix. + +### 5. AgentFS / boundary posture + +- `crates/coven-afs` with dedicated CI jobs `afs-mount-linux` / `afs-mount-macos` + (clippy+tests under the mount feature; a real-mount probe is + informational-only), plus `scripts/afs-mount-e2e.sh` / `afs-mount-smoke.sh` + and plan `2026-08-09-afs-macos-consent-confirmation`. The mount backend is + feature-gated with an informational probe rather than inheriting a generic + green test count — matching the issue's posture, though no certification + matrix for credential-observation/case-insensitivity/handle-reuse outcomes + is published. + +### 6. CI routing context + +`scripts/classify-ci-changes.py` routes docs-only changes away from the +Rust/Windows/macOS/AFS matrix (relevant to landing the scorecard document +itself); the policy guard (secret scan + privacy guard) runs on PRs. + +## What does not exist (grep- and path-verified at `1364cec`, 2026-08-30) + +- **No scorecard document anywhere** — case-insensitive grep for `scorecard` + across docs/, specs/, scripts/, crates/, workflows: 0 hits. +- **No metric-contract records** — no adopted metric carries the issue's + required fields (definition, numerator/denominator, cohort, window, source, + privacy treatment, owner-approved target, confidence, breach action). +- **No retained trend/observation history** — benchmark results are per-run + CI artifacts; nothing on `main` accumulates samples across runs. +- **No usefulness/outcome measurement** — no opt-in beta telemetry or study + harness exists (consistent with the issue's non-goals). +- **No escaped-defect / discovery-source / rollback tracking**; #805 and + #779, which would feed the release-quality rows, are both open. + +## Verdict against #807 acceptance criteria + +| # | Criterion (paraphrased) | Verdict | Basis | +| --- | --- | --- | --- | +| 1 | One current scorecard with definition/source/window/owner/confidence per row | **Not met** | No scorecard artifact exists (grep: 0 hits). | +| 2 | Journey / operation-reliability / recovery / unknown / output-loss / compatibility / release-quality rows have a baseline or are `not yet measured` with owner | **Not met as a decision view** | Instruments exist (§2–§4 above) but no view publishes baselines or an owned not-yet-measured registry. | +| 3 | Benchmark inputs/targets never displayed as achieved product results | **Partial** | Nothing violates it (no scorecard exists); existing convention already enforces the separation (README "trend data", chaos coverage vs. trend separation, 8 s product deadline noted). | +| 4 | Release certification (#779) populates the scorecard from a structured receipt | **Partial** | `certify-release.sh` (2026-08-29) + the redacted `--report-json` contract and the release-program receipt pattern exist; no receipt→scorecard pipeline, and no receipt is committed. | +| 5 | No privacy-sensitive prompts/credentials/content required to compute metrics | **Met for existing instruments** | By construction: disposable homes, fake harness fixtures, env redaction, `pc top --json` without process names, redacted certification reports carrying only the fields listed above. | +| 6 | Thresholds have explicit actions, not decorative dashboards | **Not met** | No thresholds adopted anywhere; README explicitly keeps baselines non-gating until they exist. | + +**Overall:** #807 is **not satisfied on `main`** as of `1364cec` (2026-08-30). +The measurement corpus is substantially stronger than the issue's framing +suggests (chaos diagnostics already cover output-loss, cancellation, +backpressure, and crash-recovery determinism), but the decision-grade +scorecard — the actual deliverable — does not exist. + +## What remains (critical path, dependency-ordered) + +1. **Create the scorecard** at a decided home (e.g. `docs/development/` or + `docs/reference/`) with the five row labels (**Observed current / + Historical observation / Target/SLO / Benchmark condition/input / Not yet + measured**), seeded from the instruments in §1–§4; everything without an + adopted baseline ships as `not yet measured` with an owner. +2. **Attach the metric contract** to every row (definition, cohort, window, + source+privacy treatment, confidence, breach action). No target/SLO row may + be created without an owner-approved decision. +3. **Structured receipts → scorecard**: emit machine-readable receipts from + `certify-release.sh`, `user-journey-e2e.mjs`, and the benchmark collectors + (the `release-evidence/` pattern from the v0.4.1 program), so the scorecard + links raw evidence instead of embedding tables. +4. **Sample counts before statistics**: raise benchmark iteration counts and + accumulate multi-run chaos samples (currently 3 iterations default; per-run + artifacts only) before any p95/p99 is quoted as an observed product value. +5. **Release-quality rows** land after #805 (exact-commit governance) and + #779 (per-platform artifact certification) provide their evidence feeds. +6. **Usefulness rows** stay `not yet measured` until an accountable product + decision defines the cohort and opt-in mechanism (the issue forbids + inventing adoption targets). +7. **Regression budgets with explicit breach actions** must precede any + performance check becoming gating. + +## Decision + +This record establishes the verified status of #807 for planning. It does not +implement the scorecard and introduces no measurements, targets, or SLOs. The +next dependent step is the implementation PR described in "What remains" +items 1–3; items 4–7 are explicitly blocked on the named decisions/issues, not +on further investigation. diff --git a/docs/superpowers/plans/2026-08-30-issue-859-coven-automations-v1-tracker-operationalization.md b/docs/superpowers/plans/2026-08-30-issue-859-coven-automations-v1-tracker-operationalization.md new file mode 100644 index 00000000..6a296f5b --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-issue-859-coven-automations-v1-tracker-operationalization.md @@ -0,0 +1,203 @@ +# Issue #859 Status/Decision Record — Operationalize Coven Automations v1 in Beads and GitHub roadmap mirrors + +**Date:** 2026-08-30 +**Author:** Timothy Wayne Gregg +**Scope:** Investigation and reviewed tracker-setup deliverables for +[OpenCoven/coven#859](https://github.com/OpenCoven/coven/issues/859) ("P0 control: +Operationalize Coven Automations v1 through Cave's canonical Beads graph and GitHub +mirrors"). Facts only; each claim carries an evidence link or exact command. + +--- + +## 1. What was inspected + +- Upstream `OpenCoven/coven` `main` at `1364cec` (`1364cec9dbaf1e2aca2e4544dec0e1ce807d859c`), cloned 2026-08-30. +- GitHub issues [OpenCoven/coven#854](https://github.com/OpenCoven/coven/issues/854) (opened 2026-08-30T13:36:04Z), [#816](https://github.com/OpenCoven/coven/issues/816) (2026-08-24T15:32:52Z), [#855](https://github.com/OpenCoven/coven/issues/855), [#856](https://github.com/OpenCoven/coven/issues/856), [#857](https://github.com/OpenCoven/coven/issues/857), [#858](https://github.com/OpenCoven/coven/issues/858) (all opened 2026-08-30T13:37–13:41Z), and [#859](https://github.com/OpenCoven/coven/issues/859) (2026-08-30T13:41:50Z); all open, all assigned to `BunsDev`, no labels, no milestones. +- The single comment on #859 (BunsDev, 2026-08-30T14:05:44Z, + [comment 5469149789](https://github.com/OpenCoven/coven/issues/859#issuecomment-5469149789)) + — the operational correction redirecting the canonical Beads store to Cave. +- `OpenCoven/coven-cave` issues #5219 (roadmap/operating contract, opened 2026-08-30T14:03:02Z) and #5220 (Beads/Dolt seeding, opened 2026-08-30T14:04:34Z); both open. +- `OpenCoven/coven-cave` local checkout `.beads/` directory: `issues.jsonl` (public-scrubbed export), `config.yaml` (sync remote `git+https://github.com/OpenCoven/coven-cave.git`), README, hooks. +- Upstream CI state on the base SHA (see §4). +- Search for existing work on #859: no open PRs reference 859 (`search/issues` and `repos/OpenCoven/coven/pulls?state=open`, 2026-08-30); no `859` branch on `CompleteDotTech/coven`. + +## 2. What exists on `main` today + +**The native automations foundation is implemented on `main`; the tracker graph is not.** + +- `crates/coven-cli/src/automations/` exists on main: 11 Rust files, 2,719 lines total + (`definition.rs`, `store.rs`, `occurrences.rs`, `rrule.rs`, `schedule.rs`, `runner.rs`, + `runs.rs`, `health.rs`, `import_legacy.rs`, `daemon_tick.rs`, `mod.rs`), plus daemon + integration in `crates/coven-cli/src/daemon.rs`. +- The landed series is dated 2026-08-28 and is enumerated by + [#816's program-status section](https://github.com/OpenCoven/coven/issues/816) + (in the issue body, updated 2026-08-30): PR [#846](https://github.com/OpenCoven/coven/pull/846) (routine + definitions and control actions, part 1 — commit `882fc83`) and PR + [#847](https://github.com/OpenCoven/coven/pull/847) (legacy import — merge commit + `58bc547`), with parts 5–8 as commits `39b8618`, `bd3b47d`, `a4a71af`, `1de50a8`. + #816's comment enumerates what landed: versioned routine definitions; SQLite + definition/occurrence/lease/run storage; RRULE planning; unique occurrence fencing and + bounded claim leases; expired-lease recovery, latest-only misfire, overlap refusal; + daemon recurring tick and scheduled dispatch; shared manual/scheduled launch path; + familiar ID propagation; bounded logs and atomic output delivery; health and run-history + projections; source-preserving paused legacy import; `coven.automations.*` control + actions; Cave's migration away from direct Codex ownership + ([OpenCoven/coven-cave#4990](https://github.com/OpenCoven/coven-cave/issues/4990)). +- #816 itself states it "remains open for **foundation reconciliation and exact + evidence**, not because the original architecture is still absent", and lists the + evidence required before closing (linked commit/PR series with exact final revision, + clean-clone test verification, migration/rollback proof, daemon wiring proof on + supported platforms, unified run-path proof, stale-lease proof, delivery-failure + non-success proof, criterion-by-criterion reconciliation). +- **No Beads store exists in `OpenCoven/coven`** — no `.beads/` directory on `main`. + Per the #859 operational correction this is correct: the canonical Beads graph is Cave's + embedded-Dolt database `cave` in `OpenCoven/coven-cave`; a competing Beads database must + not be initialized in `OpenCoven/coven`. +- The Automations v1 delivery epic and its `surface:shared` beads **do not exist yet**: + the `coven-cave` public-scrubbed export (`.beads/issues.jsonl`, 4 entries checked + 2026-08-30) contains only the `cave-hlv` Beads-operating epic (`cave-hlv`, + `cave-hlv.1` in_progress, `cave-hlv.2`/`.3` deferred); no bead references + `OpenCoven/coven` issues #854/#816/#855–#858. Provisioning is owned by + [OpenCoven/coven-cave#5220](https://github.com/OpenCoven/coven-cave/issues/5220). + The live Dolt database could not be read from this environment (no `bd`/`dolt` binary + available); the export is the review-visible state. +- `docs/roadmaps/` did not exist on `main`; the repo's record location is + `docs/superpowers/plans/-.md` (47 existing records, 2026-05-04 → 2026-08-23). +- No SDK, Cave, Psyche, docs, organization-canary, Familiar Contract, or Threads child issues + existed under #854 at investigation time (issue-body search returned only #859 and the + P0 graph). +- `docs/ROADMAP.md` is the public product roadmap (last updated 2026-05-26) and does not + cover the Automations v1 delivery graph. + +## 3. Issue state and the operational correction + +[#859](https://github.com/OpenCoven/coven/issues/859) was opened 2026-08-30T13:41:50Z. +Its Phase 1 says to "inspect the current Coven Beads schema/version"; the +[operational correction](https://github.com/OpenCoven/coven/issues/859#issuecomment-5469149789) +(2026-08-30T14:05:44Z) resolves the ambiguity: + +- the canonical familiar execution queue is **Cave's embedded-Dolt Beads graph**; + references to inspecting "the current Coven Beads schema" mean inspecting Cave's + canonical Beads/Dolt schema and workflow through #5220; +- roadmap and reviewed operating contract: OpenCoven/coven-cave issue #5219; +- seeding, dependency verification (`bd dep` help, `bd dep list`, `bd ready --json`), + bounded `pnpm beads:sync`, and before/after `refs/dolt/data` OIDs: #5220; +- `.beads/issues.jsonl` is a public-scrubbed review export, never canonical state; +- do **not** initialize a competing Beads database in `OpenCoven/coven`; the original + tracker roles and acceptance gates remain valid. + +Consequently #859's GitHub-side deliverables (roadmap artifact, machine-readable mapping, +drift detection, and this record) land in this repository through review, while bead +creation stays with #5220 in `OpenCoven/coven-cave`. + +## 4. Pre-change integrity/status report (2026-08-30) + +| Check | Result | +| --- | --- | +| Working tree | clean before this change (only `docs/roadmaps/` additions by this branch) | +| Base SHA | `1364cec9dbaf1e2aca2e4544dec0e1ce807d859c` (even with upstream `main` and fork `main`) | +| `docs/superpowers/plans/` | 47 records present, latest `2026-08-23-maintenance-participant.md` | +| `docs/roadmaps/` | absent on base (created by this branch per #859's suggested path) | +| Upstream CI on base SHA | `CI` run 33309176793 (2026-08-30T11:32:21Z) **failed** at "Classify changes" — `scripts/classify-ci-changes.py` raised `ValueError: no paths provided` on the empty-diff `push` to `main` for commit `1364cec` ("chore: preserve consolidated branch ancestry"). This is a push-event classification edge case, not a PR-path failure: PR classification uses `PR_BASE_SHA...PR_HEAD_SHA`, which always contains paths. No other CI workflow run failed on the base SHA; the `Engine bump` workflow succeeded on the same SHA at 2026-08-30T13:48:51Z. | +| Upstream open PRs touching this area | none found for issue #859 | +| Fork (`CompleteDotTech/coven`) branch state | `main` mirrors upstream `main` at the same SHA; no `859` branch existed before this work | +| Beads export cross-check | `node docs/roadmaps/drift-check.mjs --beads-export .beads/issues.jsonl` (run against the `coven-cave` checkout export) → no errors; confirms zero Automations v1 beads and no sensitive payloads in the export | + +## 5. Decisions + +- **D1 — one canonical writer.** Exactly one checkout/process is designated schema + migrator and canonical writer for this setup; persisted tracker changes land only + through reviewed PRs (this branch/PR for GitHub-side artifacts; #5220's checkout for + Bead-side provisioning). Concurrent independent migrations and direct writes from + unrelated worktrees are refused. The `coven claim` registry could not be used here + (no Rust toolchain in this environment to build the CLI); REST deconfliction (§1) plus + a dedicated clone and branch satisfy the anti-duplication intent. +- **D2 — reuse, don't duplicate.** No bead mapping #816 was found in the review-visible + export, so nothing is duplicated: #816's mapping entry is declared once in the mapping + file with `provisioning` pointing at #5220, which owns the reuse-check against the live + Dolt store before creating anything. +- **D3 — no competing Beads store.** No `.beads/` is initialized in `OpenCoven/coven`; + the operational correction is honored verbatim. +- **D4 — mapping lives in both worlds correctly.** GitHub owns the public roadmap + artifact and the machine-readable mapping contract (this PR); Beads owns the + implementation dependency graph and execution state once provisioned. The mapping file + is the reconciliation contract; Bead IDs stay `null` (warn-level `W010`) until #5220 + declares them, after which a one-line reviewed change flips provisioning to `done` and + missing-mapping drift escalates to `error`. +- **D5 — drift detection without credentials.** `docs/roadmaps/drift-check.mjs` is + dependency-free, offline, and CI-safe; it verifies mapping-internal invariants, the + generated roadmap block (generator-contract enforcement), and an optional local Beads + export, and scans tracker output for sensitive payloads. `--selftest` proves every + detection class with fixtures (all 11 fixtures and 6 sensitive-payload rules pass). +- **D6 — severity policy.** `error` findings fail CI; `warning` findings (today: pending + provisioning) report without failing. This keeps the check honest (it reports the real + gap) without keeping CI red on work owned by another repository. +- **D7 — no premature closure.** This PR references #859 without `Closes`; the issue's + own completion semantics (#816 stays open until its evidence checklist is done; #859 + spans provisioning in `coven-cave`) mean nothing is closed by tracker work alone. + +## 6. Verdict against #859's acceptance criteria + +| # | Criterion | Verdict | +| --- | --- | --- | +| 1 | #854, #816, #855–#858 each map to exactly one Bead | **PARTIAL** — the one-to-one contract is committed (mapping file: 6 outcomes, unique slugs/labels/refs, enforced by `E001`/`E002`), but bead IDs are `null` until #5220 provisions them; drift check reports `W010` per outcome. | +| 2 | Cross-repository child outcomes map one-to-one as created | **SATISFIED (vacuously today)** — no child outcomes existed at sync time; `cross_repository_children` is empty and the policy + `E101` enforcement are in place. | +| 3 | Dependencies and P0/P1/P2 priorities match the canonical roadmap | **SATISFIED** — mapping `depends_on` mirrors #859's minimum graph; the roadmap table is generated from the same file, and any hand edit is flagged `E008`. | +| 4 | One writer/schema owner and reviewed change path documented | **SATISFIED** — D1 above; also recorded in the roadmap sync metadata. | +| 5 | Roadmap artifact and machine-readable mapping committed through review | **SATISFIED BY THIS PR** — `docs/roadmaps/coven-automations-v1.md` + `coven-automations-v1.mapping.json` + `drift-check.mjs`. | +| 6 | Drift detection catches state, priority, parent, evidence, generated-mirror disagreement | **SATISFIED** — classes `E001`–`E009` (plus `E100`–`E104` in export mode) cover state, priority, parent/dependency, evidence, generated-mirror, and sensitive payloads; proven by `--selftest`. | +| 7 | No tracker data treated as automation runtime truth | **SATISFIED (documented)** — canonical tracker roles in the roadmap and mapping invariants state it; the Coven runtime owns occurrences/runs/leases/approvals/receipts. | +| 8 | Final #854 release rollup generable from reconciled state and exact evidence | **PENDING** — the mapping carries evidence links and gates so the rollup is generable in principle, but certification evidence does not exist yet (no #855–#858 outcomes started; #816's evidence checklist open). | + +## 7. What remains + +1. Provision the Automations v1 delivery epic and six `surface:shared` beads in Cave's + canonical Beads/Dolt graph with dependency verification and bounded + `pnpm beads:sync`, recording before/after `refs/dolt/data` OIDs — + OpenCoven/coven-cave issue #5220 + (P0, on the critical path for criterion 1). +2. Land Cave's reviewed operating contract — OpenCoven/coven-cave issue #5219. +3. Fill #816's evidence checklist (criterion 1's "close only after" condition and the + foundation release gate). +4. After provisioning: set the real bead IDs in + `docs/roadmaps/coven-automations-v1.mapping.json` (one reviewed change; `W010` clears; + `E101` escalation arms). +5. Wire `node docs/roadmaps/drift-check.mjs` into relevant PR CI and the weekly program + rollup cadence once provisioning exists (the check itself needs no credentials). +6. Create the P1 cross-repository outcomes (SDK, Cave, Psyche, docs, + organization-canary, Familiar Contract, Threads) under #854 and map them one-to-one as + they appear, with explicit `depends_on`/`depends_on_external` edges. +7. #855–#858 implementation work per the graph below. + +## 8. Critical path (before/after P0 dependency graph) + +**Before this PR:** no recorded graph in this repository — the P0 ordering existed only +in #859's prose; the bead side did not exist at all. + +**After this PR (GitHub side recorded; bead provisioning pending → #5220):** + +```text +#854 program (P0 control — release gates, rollup) + └─ gate 1: #816 foundation (P0 — landed 2026-08-28, evidence reconciliation open) + ├─ #855 protocol (P0, depends: foundation) + │ ├─ #856 scheduler (P0, depends: foundation, protocol) + │ └─ #857 authority (P0, depends: foundation, protocol; + upstream Familiar/Threads profiles when created) + └─ #858 certification (P0, depends: protocol, scheduler, authority) + └─ v1 release gate (owned by #854) +``` + +## 9. Initial evidence packet + +- Pre-change tracker report: §4 above plus the `drift-check` export cross-check (no + Automations v1 beads; export contains only the `cave-hlv` operating epic). +- Created/reused bead IDs: none created by this repository (forbidden by the operational + correction); provisioning delegated to OpenCoven/coven-cave#5220 (D2/D3). +- Beads version/schema: Beads 1.2.2, schema v53, as recorded in + [2026-08-20-coven-v0.4.1-release-program.md](./2026-08-20-coven-v0.4.1-release-program.md); + live verification owned by #5220. +- Reviewed PR: this branch — + `agent/issue-859-p0-control-operationalize-coven-automations-v1` based on `1364cec`. +- Drift report: `node docs/roadmaps/drift-check.mjs` → 0 errors, 6 × `W010` (pending + provisioning); `--selftest` → pass. +- Final mapping: `docs/roadmaps/coven-automations-v1.mapping.json` (schema version 1). +- Before/after P0 dependency graph: §8. diff --git a/docs/superpowers/specs/2026-08-14-coven-agents-rust-design.md b/docs/superpowers/specs/2026-08-14-coven-agents-rust-design.md index 45c1846b..884679e6 100644 --- a/docs/superpowers/specs/2026-08-14-coven-agents-rust-design.md +++ b/docs/superpowers/specs/2026-08-14-coven-agents-rust-design.md @@ -59,7 +59,9 @@ The runner: 2. runs starting-agent input guardrails before any model or tool side effect; 3. loads optional session history; 4. calls the active model; -5. executes tool calls sequentially or changes active agent for one handoff; +5. executes tool calls sequentially or changes active agent for one handoff, + running the target agent's input guardrails against the original user input + before its first model turn; 6. repeats within explicit turn and handoff limits; 7. runs final-agent output guardrails; 8. appends successful runs to the session; diff --git a/scripts/cli-docs-test.mjs b/scripts/cli-docs-test.mjs index e96b1942..0aaa1be5 100644 --- a/scripts/cli-docs-test.mjs +++ b/scripts/cli-docs-test.mjs @@ -5,7 +5,16 @@ import test from 'node:test'; const coreGuideDocs = [ { path: 'docs/development/cli-core-functionality.md', - required: ['Command ownership', 'Access contract', 'coven doctor --json', 'coven daemon status --json'] + required: [ + 'Command ownership', + 'Access contract', + 'coven doctor --json', + 'coven daemon status --json', + 'coven help --all --json', + '"schemaVersion": 1', + 'docsUrl', + 'scripts/export-cli-help-contract.mjs' + ] }, { path: 'docs/guides/index.md', @@ -21,7 +30,7 @@ const coreGuideDocs = [ }, { path: 'docs/guides/automation-json.md', - required: ['coven doctor --json', 'coven daemon status --json', 'coven sessions --json'] + required: ['coven doctor --json', 'coven daemon status --json', 'coven sessions --json', 'coven help --all --json', 'schemaVersion'] }, { path: 'docs/guides/multi-agent-worktrees.md', diff --git a/spec/coven-automations/v1/README.md b/spec/coven-automations/v1/README.md new file mode 100644 index 00000000..66cd0311 --- /dev/null +++ b/spec/coven-automations/v1/README.md @@ -0,0 +1,38 @@ +# Coven Automations v1 (`coven.automations.v1`) + +Machine-readable contract artifacts for the Coven automations protocol defined in [`docs/architecture/coven-automations-v1.md`](../../../docs/architecture/coven-automations-v1.md) (OpenCoven/coven issue #855, foundation #816). + +Cave, the SDK, Psyche adapters, runtimes, and future implementations consume these artifacts — never Coven internals, never hand-maintained parallel types. + +## Artifacts + +| File | Purpose | +| --- | --- | +| `protocol-version.json` | Contract profile registry; contract version is separate from implementation/release version. | +| `capabilities.json` | Variant negotiation, including explicit negative negotiation (`refused`) — unknown variants fail closed with `CAPABILITY_UNSUPPORTED`. | +| `common.schema.json` | Shared value definitions (ids, digests, principals, timestamps, extension bag). | +| `automation-definition.schema.json` | `AutomationDefinition`: identity, monotonic revision + integrity digest, versioned trigger/condition/action unions, binding, policies, provenance. | +| `automation-occurrence.schema.json` | `AutomationOccurrence`: occurrence key, exact definition revision pin, fence/lease, cancellation/recovery, event window. | +| `automation-run.schema.json` | `AutomationRun`: exact familiar/principal/authority/runtime binding, attempts, terminal disposition, delivery, receipt reference. | +| `automation-attempt.schema.json` | `AutomationAttempt`: adoption key, dispatch fence, worker correlation, retry classification, cursors, ambiguous disposition. | +| `automation-receipt.schema.json` | `AutomationReceipt`: immutable versioned receipt with digests, side-effect class, integrity/authentication, privacy/retention. | +| `command-envelope.schema.json` | Every command (create, revise, activate, pause, disable, tombstone, run now, cancel, retry/recover, list/get/history/health, events read/subscribe, legacy import) + response envelope with adoption-key semantics. | +| `error-envelope.schema.json` | Typed error codes and the frozen HTTP/control-action status mapping. | +| `event-envelope.schema.json` | Changefeed envelope: streams, gapless sequences, event ids, causation, compaction snapshots. | +| `state-machines.json` | Authoritative lifecycle state machines (definition, occurrence, run, attempt) plus the ten normative invariants. | +| `compatibility-matrix.json` | Machine-readable change classes, per-field status, and explicit incompatible-profile refusal rules. | +| `test-vectors.json` | Golden vectors: valid, invalid, unknown-field, downgrade/upgrade, unknown-variant, adoption replay/conflict, revision conflict, duplicate/out-of-order event replay — with pinned RFC 8785 digests. | +| `coven.automations.v1.d.ts` | Pinned TypeScript projection of the schemas for SDK/Cave canaries. | + +## Compatibility rules + +- Unknown schema versions fail closed: `SCHEMA_VERSION_UNSUPPORTED`, never approximation. +- Unknown trigger/condition/action/policy variants fail closed: `CAPABILITY_UNSUPPORTED` naming the variant. +- Unknown fields fail closed (`additionalProperties: false`); optional data travels only in the namespaced `extensions` bag, which is preserved and never interpreted until promoted by a new profile. +- Digests are SHA-256 over RFC 8785 (JCS) canonical JSON — never over ad-hoc serialization. +- Contract profile (`coven.automations.v1`) is independent of implementation release versions. +- Historical records pin the exact definition revision and digest they were created and executed against, and are never reinterpreted by current definitions. + +## Conformance + +Required test suites and canary requirements (Coven, SDK, Cave — each against packed/released artifacts, not source-relative imports) are listed in `conformance-manifest.json`. Golden vectors are self-contained: any draft 2020-12 validator plus the digest recipe in `test-vectors.json` suffices to run them outside the Coven crate. diff --git a/spec/coven-automations/v1/automation-attempt.schema.json b/spec/coven-automations/v1/automation-attempt.schema.json new file mode 100644 index 00000000..32a5c378 --- /dev/null +++ b/spec/coven-automations/v1/automation-attempt.schema.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/automation-attempt.schema.json", + "title": "Coven Automations v1 AutomationAttempt", + "description": "One dispatch unit of one run. Attempts are individually fenced and individually terminal: a retry always creates a new attempt and never rewrites a prior attempt. An attempt binds at most one runtime session.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "attemptId", + "runId", + "occurrenceId", + "attemptNumber", + "adoptionKey", + "dispatchFence", + "state" + ], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "attemptId": { "$ref": "common.schema.json#/$defs/attemptId" }, + "runId": { "$ref": "common.schema.json#/$defs/runId" }, + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "attemptNumber": { + "type": "integer", + "minimum": 1, + "description": "Monotonic within the run, starting at 1. attemptNumber n is never reused within a run." + }, + "adoptionKey": { + "$ref": "common.schema.json#/$defs/adoptionKey", + "description": "Request/adoption key for this dispatch. Workers adopt by key; replays of the same key return the same attempt rather than creating a second one. Derived deterministically from the run id and attempt number unless the caller supplies one." + }, + "priorDisposition": { + "type": "object", + "additionalProperties": false, + "required": ["attemptNumber", "outcome"], + "properties": { + "attemptNumber": { + "type": "integer", + "minimum": 1, + "description": "The prior attempt this attempt retries, when applicable." + }, + "outcome": { + "enum": ["failed", "timed_out", "ambiguous", "cancelled"], + "description": "Explicit prior disposition the retry is issued against. Retrying requires stating this; an ambiguous prior disposition forbids automatic retry and requires an explicit recover command." + } + }, + "description": "Required when attemptNumber > 1: every retry names the disposition it retries." + }, + "dispatchFence": { + "type": "object", + "additionalProperties": false, + "required": ["occurrenceFenceGeneration", "dispatchGeneration"], + "properties": { + "occurrenceFenceGeneration": { "$ref": "common.schema.json#/$defs/fenceToken" }, + "dispatchGeneration": { + "type": "integer", + "minimum": 1, + "description": "Monotonic dispatch counter guarding double-dispatch of the same attempt." + } + } + }, + "workerCorrelation": { + "type": "object", + "additionalProperties": false, + "required": ["workerId"], + "properties": { + "workerId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Dispatcher/worker instance that adopted this attempt." + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Runtime session bound to this attempt. At most one session may bind to one attempt; a second bind is a typed error (ILLEGAL_TRANSITION), never a silent overwrite." + }, + "adoptedAt": { "$ref": "common.schema.json#/$defs/timestamp" } + } + }, + "retryClassification": { + "type": "object", + "additionalProperties": false, + "properties": { + "classification": { + "enum": ["initial", "automatic_retry", "operator_retry", "operator_recovery"] + }, + "eligibleClasses": { + "type": "array", + "items": { + "enum": ["transient_dispatch", "lease_expired", "runtime_unavailable"] + }, + "description": "Snapshot of retryable classes from the definition's retry policy when this attempt was opened." + } + } + }, + "leaseObservations": { + "type": "array", + "maxItems": 1024, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["observedAt", "heartbeatOk"], + "properties": { + "observedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "heartbeatOk": { "type": "boolean" }, + "note": { "type": "string", "maxLength": 200 } + } + }, + "description": "Heartbeat/lease observations. Expired evidence moves work to recovery; it can never be reinterpreted as success." + }, + "outputCursors": { + "type": "object", + "additionalProperties": false, + "properties": { + "eventCursor": { "$ref": "common.schema.json#/$defs/sequenceNumber" }, + "logCursor": { + "type": "integer", + "minimum": 0, + "description": "Cursor into the bounded run log the worker has consumed." + } + }, + "description": "Output/event cursors a resuming worker uses to continue observing without re-emitting." + }, + "state": { + "enum": [ + "adopted", + "dispatching", + "started", + "observing", + "succeeded", + "failed", + "cancelled", + "timed_out", + "ambiguous" + ], + "description": "Attempt lifecycle per state-machines.json. ambiguous is terminal: absence of runtime evidence cannot become success, and ambiguous work is never automatically retried." + }, + "stateReason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "openedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "settledAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "extensions": { "$ref": "common.schema.json#/$defs/extensionBag" } + } +} diff --git a/spec/coven-automations/v1/automation-definition.schema.json b/spec/coven-automations/v1/automation-definition.schema.json new file mode 100644 index 00000000..7436d072 --- /dev/null +++ b/spec/coven-automations/v1/automation-definition.schema.json @@ -0,0 +1,317 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/automation-definition.schema.json", + "title": "Coven Automations v1 AutomationDefinition", + "description": "Durable, Coven-owned automation definition. The definition is the identity and intent anchor; execution state lives in occurrences, runs, and attempts. Unknown top-level fields fail closed; optional data travels only in `extensions`.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "automationId", + "revision", + "integrity", + "lifecycleState", + "display", + "trigger", + "action", + "binding", + "policies" + ], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Monotonic per-automation revision. Incremented by exactly one on every accepted mutating command. Occurrences and runs pin the revision they were created and executed against, and are never reinterpreted by later revisions." + }, + "integrity": { + "$ref": "common.schema.json#/$defs/digest", + "description": "SHA-256 over the RFC 8785 canonical serialization of this object with the `integrity` member removed. Pins the exact definition body a revision means." + }, + "lifecycleState": { + "enum": ["draft", "paused", "active", "disabled", "invalid"], + "description": "Definition lifecycle per state-machines.json. New definitions start in draft; nothing runs until an explicit activate commits active. Tombstoning is not a state here: it is recorded by the deletion marker and this object is retained for history." + }, + "deletion": { + "type": "object", + "additionalProperties": false, + "required": ["tombstoned", "requestedAt"], + "properties": { + "tombstoned": { "const": true }, + "requestedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "requestedBy": { "$ref": "common.schema.json#/$defs/principalRef" }, + "reason": { "type": "string", "maxLength": 500 } + }, + "description": "Present if and only if the definition is tombstoned. A tombstoned definition never plans, claims, or runs; its historical occurrences, runs, attempts, and receipts are retained." + }, + "display": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 160 }, + "description": { "type": "string", "maxLength": 2000 }, + "tags": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 64 }, + "maxItems": 64, + "uniqueItems": true + } + } + }, + "trigger": { + "oneOf": [ + { "$ref": "#/$defs/scheduleTrigger" } + ], + "description": "Exactly one versioned trigger union. v1 ships the schedule variant; the union admits future variants as new oneOf branches without redefining v1 fields. Consumers must fail closed on unrecognized variants (negative capability negotiation)." + }, + "conditions": { + "type": "array", + "items": { "$ref": "#/$defs/condition" }, + "maxItems": 16, + "default": [], + "description": "Zero or more versioned conditions. v1 defines none; the slot exists so future variants are additive." + }, + "action": { + "oneOf": [ + { "$ref": "#/$defs/familiarInvocationAction" } + ], + "description": "Exactly one versioned action union." + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": ["familiarBindingPolicy", "authority"], + "properties": { + "familiarBindingPolicy": { + "enum": ["exact"], + "description": "v1 ratifies exact binding only: the familiar recorded at activation is the familiar every run binds. Rebinding requires a new revision and re-activation." + }, + "familiarId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Required when familiarBindingPolicy is exact. This protocol references familiar identity; it never defines it." + }, + "authority": { + "$ref": "common.schema.json#/$defs/approvalRef", + "description": "Authority/approval policy reference. Approval decisions live in the canonical authority layer." + } + } + }, + "runtimeRequirements": { + "$ref": "common.schema.json#/$defs/runtimeDescriptor", + "description": "Runtime descriptor and capability requirements every run must satisfy. Required for active definitions." + }, + "policies": { + "type": "object", + "additionalProperties": false, + "required": ["timeout", "retry", "concurrency", "misfire", "retention"], + "properties": { + "timeout": { + "type": "object", + "additionalProperties": false, + "required": ["perRunMinutes"], + "properties": { + "perRunMinutes": { + "type": "integer", + "minimum": 1, + "maximum": 44640, + "description": "Bounded and required, matching the #816 validator (1..=44640 minutes)." + } + } + }, + "retry": { + "type": "object", + "additionalProperties": false, + "required": ["maxAttempts", "backoffPolicy"], + "properties": { + "maxAttempts": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 1, + "description": "Maximum attempts per run. Attempts are new, individually fenced units; the prior attempt is never rewritten. Ambiguous dispositions are never auto-retried." + }, + "backoffPolicy": { + "enum": ["none", "fixed", "exponential"], + "description": "Retry classification consumed by the dispatcher. Fixed backoff requires backoffSeconds." + }, + "backoffSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + }, + "retryableClasses": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["transient_dispatch", "lease_expired", "runtime_unavailable"] + }, + "description": "Failure classes eligible for an automatic next attempt. Everything else, especially ambiguous, requires an explicit recover command." + } + } + }, + "concurrency": { + "type": "object", + "additionalProperties": false, + "required": ["overlap"], + "properties": { + "overlap": { + "enum": ["forbid"], + "description": "v1 ratifies forbid only: a run is skipped when the previous occurrence has not settled (#816 semantic)." + } + } + }, + "misfire": { + "type": "object", + "additionalProperties": false, + "required": ["disposition"], + "properties": { + "disposition": { + "enum": ["latest"], + "description": "v1 ratifies latest only: on recovery, exactly the latest missed slot is fenced; earlier slots collapse and are recorded with misfireDisposition collapsed_to_latest." + } + } + }, + "delivery": { + "type": "object", + "additionalProperties": false, + "properties": { + "outputTarget": { + "type": "string", + "maxLength": 1024, + "description": "Optional atomic output path. Delivery commits there only on a completed, verified run." + }, + "mode": { + "enum": ["atomic"], + "description": "Required when outputTarget is present. v1 supports atomic writes only." + } + } + }, + "retention": { + "type": "object", + "additionalProperties": false, + "required": ["occurrenceHistory"], + "properties": { + "occurrenceHistory": { "$ref": "common.schema.json#/$defs/retentionClass" }, + "runLogs": { "$ref": "common.schema.json#/$defs/retentionClass" }, + "receipts": { "$ref": "common.schema.json#/$defs/retentionClass" } + } + } + } + }, + "provenance": { + "$ref": "common.schema.json#/$defs/provenance" + }, + "activation": { + "$ref": "common.schema.json#/$defs/activationWindow" + }, + "extensions": { "$ref": "common.schema.json#/$defs/extensionBag" } + }, + "allOf": [ + { + "if": { "properties": { "lifecycleState": { "enum": ["active", "paused", "disabled"] } } }, + "then": { + "required": ["runtimeRequirements"], + "properties": { + "binding": { "required": ["familiarBindingPolicy", "familiarId", "authority"] } + } + } + }, + { + "if": { + "properties": { + "policies": { + "properties": { + "delivery": { "required": ["outputTarget"] } + } + } + } + }, + "then": { + "properties": { + "policies": { + "properties": { + "delivery": { "required": ["outputTarget", "mode"] } + } + } + } + } + }, + { + "if": { + "properties": { + "policies": { + "properties": { + "retry": { + "properties": { "backoffPolicy": { "const": "fixed" } } + } + } + } + } + }, + "then": { + "properties": { + "policies": { + "properties": { + "retry": { "required": ["maxAttempts", "backoffPolicy", "backoffSeconds"] } + } + } + } + } + } + ], + "$defs": { + "scheduleTrigger": { + "type": "object", + "additionalProperties": false, + "required": ["variant", "version", "schedule"], + "properties": { + "variant": { "const": "schedule" }, + "version": { "const": 1 }, + "schedule": { + "type": "object", + "additionalProperties": false, + "required": ["rrule", "timezone"], + "properties": { + "rrule": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Scoped RRULE text per the #816 scheduler vocabulary: FREQ=DAILY|WEEKLY, optional BYHOUR list, optional BYDAY list for weekly. Anything else is refused at validation." + }, + "timezone": { + "enum": ["local", "utc"] + } + } + } + } + }, + "condition": { + "$comment": "v1 defines zero condition variants. The boolean-false schema matches nothing, so any value in `conditions` fails validation until a future profile adds branches here additively.", + "not": {} + }, + "familiarInvocationAction": { + "type": "object", + "additionalProperties": false, + "required": ["variant", "version", "prompt"], + "properties": { + "variant": { "const": "familiarInvocation" }, + "version": { "const": 1 }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 100000, + "description": "Non-empty prompt executed verbatim; mirrors the #816 prompt requirement." + }, + "cwd": { + "type": "string", + "maxLength": 1024, + "description": "Working directory for the invocation. Runs without a resolvable cwd fail with a recorded reason rather than guessing a project (#816 runner semantic)." + } + } + } + } +} diff --git a/spec/coven-automations/v1/automation-occurrence.schema.json b/spec/coven-automations/v1/automation-occurrence.schema.json new file mode 100644 index 00000000..c3ce1459 --- /dev/null +++ b/spec/coven-automations/v1/automation-occurrence.schema.json @@ -0,0 +1,176 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/automation-occurrence.schema.json", + "title": "Coven Automations v1 AutomationOccurrence", + "description": "One scheduled or requested execution slot of one exact definition revision. Occurrences are immutable history anchors: their automationRevision never changes, and terminal states never regress. State transitions are governed by state-machines.json.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "occurrenceId", + "automationId", + "automationRevision", + "triggerIdentity", + "occurrenceKey", + "scheduledFor", + "state", + "stateReason", + "fence", + "createdAt", + "updatedAt" + ], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "automationRevision": { + "type": "integer", + "minimum": 1, + "description": "Exact definition revision this occurrence executes against. Never rewritten when the definition is revised; historical occurrences are never reinterpreted by current definitions." + }, + "triggerIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "enum": ["schedule.slot", "manual.request"] + }, + "rruleRef": { + "type": "string", + "maxLength": 512, + "description": "Required for schedule.slot: the RRULE the slot was computed from." + }, + "requestedBy": { "$ref": "common.schema.json#/$defs/principalRef" } + } + }, + "occurrenceKey": { + "type": "string", + "minLength": 1, + "maxLength": 320, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@:-]*$", + "description": "Canonical occurrence key: `automationId@scheduledFor` for schedule slots (scheduledFor in RFC 3339 millis), `automationId@manual-` for manual requests. The store fence UNIQUE(automation_id, scheduled_for) of #816 is the implementation of this key." + }, + "scheduledFor": { "$ref": "common.schema.json#/$defs/timestamp" }, + "observedAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "When the trigger was observed by the planner; may differ from scheduledFor by misfire disposition." + }, + "eligibleAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "When the occurrence passed eligibility (conditions and policies) and became claimable." + }, + "state": { + "enum": [ + "planned", + "eligible", + "claimed", + "dispatching", + "running", + "recovering", + "recovery_required", + "succeeded", + "failed", + "cancelled", + "timed_out", + "skipped", + "superseded" + ], + "description": "Occurrence lifecycle state per state-machines.json." + }, + "stateReason": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Machine-readable reason the current state was entered, for example lease_expired, launch_refused, overlap_forbid, superseded_by_revision_3." + }, + "fence": { + "type": "object", + "additionalProperties": false, + "required": ["generation"], + "properties": { + "generation": { "$ref": "common.schema.json#/$defs/fenceToken" }, + "claimedBy": { + "type": "string", + "maxLength": 128, + "description": "Claimant identity (scheduler or dispatcher instance)." + }, + "leaseExpiresAt": { "$ref": "common.schema.json#/$defs/timestamp" } + } + }, + "misfireDisposition": { + "$ref": "common.schema.json#/$defs/misfireDisposition", + "default": "none" + }, + "createdAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "When the occurrence was fenced; mirrors the #816 store column." + }, + "updatedAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "When the occurrence row last changed; mirrors the #816 store column." + }, + "claimMetadata": { + "type": "object", + "additionalProperties": false, + "required": ["claimedAt", "leaseMinutes"], + "properties": { + "claimedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "leaseMinutes": { + "type": "integer", + "minimum": 1, + "maximum": 1440, + "description": "Bounded lease per #816 claim semantics (1..=1440 minutes)." + } + } + }, + "activeRunRef": { + "$ref": "common.schema.json#/$defs/runId", + "description": "Present while exactly one accepted run owns this occurrence fence. At most one run may be accepted per fence generation." + }, + "cancellation": { + "type": "object", + "additionalProperties": false, + "required": ["requestedAt"], + "properties": { + "requestedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "requestedBy": { "$ref": "common.schema.json#/$defs/principalRef" }, + "acknowledgedAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "Cancellation is a request until acknowledged or reconciled; this field records the acknowledgment, not the intent." + }, + "reconciledAt": { "$ref": "common.schema.json#/$defs/timestamp" } + } + }, + "recovery": { + "type": "object", + "additionalProperties": false, + "required": ["enteredAt"], + "properties": { + "enteredAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "evidence": { + "enum": ["lease_expired", "dispatch_unconfirmed", "runtime_lost"], + "description": "Expired or missing runtime evidence that moved this occurrence into recovering/recovery_required." + }, + "resolvedDisposition": { + "enum": ["failed_deterministic", "failed_ambiguous"], + "description": "Set when recovery resolves. failed_deterministic means no side effects were possible; failed_ambiguous means side effects cannot be ruled out and no automatic retry may occur." + } + } + }, + "eventWindow": { + "type": "object", + "additionalProperties": false, + "required": ["firstSequence", "lastSequence"], + "properties": { + "firstSequence": { "$ref": "common.schema.json#/$defs/sequenceNumber" }, + "lastSequence": { + "$ref": "common.schema.json#/$defs/sequenceNumber", + "description": "Inclusive upper bound of this occurrence's stream events at the time this representation was read." + } + }, + "description": "Event sequence boundaries in the occurrence's authoritative stream, enabling clients to resume change reading without gaps." + }, + "extensions": { "$ref": "common.schema.json#/$defs/extensionBag" } + } +} diff --git a/spec/coven-automations/v1/automation-receipt.schema.json b/spec/coven-automations/v1/automation-receipt.schema.json new file mode 100644 index 00000000..35c8312b --- /dev/null +++ b/spec/coven-automations/v1/automation-receipt.schema.json @@ -0,0 +1,141 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/automation-receipt.schema.json", + "title": "Coven Automations v1 AutomationReceipt", + "description": "Immutable, versioned proof of what one attempt actually did. A receipt is written once, never revised, and binds digests of everything that determined the outcome. Receipts are the audit anchor for ambiguous-evidence questions.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "receiptId", + "automationId", + "automationRevision", + "occurrenceId", + "runId", + "attemptId", + "identity", + "outcome", + "sideEffectClass", + "producedAt", + "producer", + "integrity", + "privacy" + ], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "receiptId": { "$ref": "common.schema.json#/$defs/receiptId" }, + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "automationRevision": { + "type": "integer", + "minimum": 1, + "description": "Exact definition revision executed; the receipt also carries the definition digest so the meaning of that revision is pinned even if the definition store is lost." + }, + "definitionDigest": { "$ref": "common.schema.json#/$defs/digest" }, + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "occurrenceFenceGeneration": { "$ref": "common.schema.json#/$defs/fenceToken" }, + "runId": { "$ref": "common.schema.json#/$defs/runId" }, + "attemptId": { "$ref": "common.schema.json#/$defs/attemptId" }, + "attemptNumber": { "type": "integer", "minimum": 1 }, + "identity": { + "$ref": "common.schema.json#/$defs/familiarRef", + "description": "Exact familiar identity that executed." + }, + "authority": { + "type": "object", + "additionalProperties": false, + "required": ["principal"], + "properties": { + "principal": { "$ref": "common.schema.json#/$defs/principalRef" }, + "approval": { "$ref": "common.schema.json#/$defs/approvalRef" } + } + }, + "runtime": { + "$ref": "common.schema.json#/$defs/runtimeDescriptor", + "description": "Runtime descriptor and capabilities actually exercised." + }, + "deliveryDigest": { "$ref": "common.schema.json#/$defs/digest" }, + "resultDigest": { "$ref": "common.schema.json#/$defs/digest" }, + "exercisedCapabilities": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 96 }, + "uniqueItems": true, + "maxItems": 128, + "description": "Capability keys the run actually exercised, as observed by the runtime." + }, + "sideEffectClass": { + "enum": [ + "none", + "local_read", + "local_write", + "external_read", + "external_mutation", + "irreversible_external_mutation" + ], + "description": "Most severe side-effect class the attempt reached. Drives whether an ambiguous disposition may be resolved as failed_deterministic." + }, + "outcome": { + "type": "object", + "additionalProperties": false, + "required": ["disposition"], + "properties": { + "disposition": { + "enum": ["succeeded", "failed", "cancelled", "timed_out", "ambiguous"] + }, + "failureClass": { "type": "string", "maxLength": 96 }, + "detail": { "type": "string", "maxLength": 2000 }, + "partialFailures": { + "type": "array", + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["step", "reason"], + "properties": { + "step": { "type": "string", "minLength": 1, "maxLength": 128 }, + "reason": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "recovered": { "type": "boolean" } + } + }, + "description": "Per-step partial failures with recovery disposition." + }, + "recoveryDisposition": { + "enum": ["not_required", "recovered_inline", "deferred_to_operator"] + } + } + }, + "producedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "producer": { "$ref": "common.schema.json#/$defs/producerIdentity" }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "canonicalization", "value"], + "properties": { + "algorithm": { "const": "sha256" }, + "canonicalization": { "const": "jcs-rfc8785" }, + "value": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "authentication": { + "enum": ["none", "producer-hmac", "cosign"], + "description": "Authentication applied over the digest by the producing deployment. Receipts without authentication are integrity-checked but not provenance-proof; consumers must surface the distinction." + } + }, + "description": "SHA-256 over the RFC 8785 canonical serialization of this receipt with the `integrity` member removed. The immutable body plus this field is the receipt's integrity/authentication story in v1." + }, + "privacy": { + "type": "object", + "additionalProperties": false, + "required": ["classification", "retention"], + "properties": { + "classification": { "$ref": "common.schema.json#/$defs/privacyClassification" }, + "retention": { "$ref": "common.schema.json#/$defs/retentionClass" }, + "notes": { + "type": "string", + "maxLength": 500, + "description": "Privacy-relevant facts a reviewer must know, for example whether the prompt body is retained." + } + } + } + } +} diff --git a/spec/coven-automations/v1/automation-run.schema.json b/spec/coven-automations/v1/automation-run.schema.json new file mode 100644 index 00000000..6f1008aa --- /dev/null +++ b/spec/coven-automations/v1/automation-run.schema.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/automation-run.schema.json", + "title": "Coven Automations v1 AutomationRun", + "description": "One accepted execution of one occurrence, binding the exact familiar, principal/authority, and runtime used. A run owns a sequence of attempts; retries create new attempts and never rewrite the run's recorded bindings.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "runId", + "occurrenceId", + "automationId", + "automationRevision", + "binding", + "state", + "attemptCount", + "startedAt" + ], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "runId": { "$ref": "common.schema.json#/$defs/runId" }, + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "automationRevision": { + "type": "integer", + "minimum": 1, + "description": "Exact definition revision the run was accepted under." + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": ["familiar", "authority", "runtime"], + "properties": { + "familiar": { + "$ref": "common.schema.json#/$defs/familiarRef", + "description": "Exact familiar identity at dispatch time." + }, + "authority": { + "type": "object", + "additionalProperties": false, + "required": ["principal"], + "properties": { + "principal": { "$ref": "common.schema.json#/$defs/principalRef" }, + "approval": { "$ref": "common.schema.json#/$defs/approvalRef" }, + "authenticationClass": { + "type": "string", + "maxLength": 64, + "description": "Authentication class presented at dispatch, recorded verbatim from the authority layer." + } + }, + "description": "Exact principal/authority/approval binding. This protocol records the binding; authority semantics live in their canonical layer." + }, + "runtime": { + "$ref": "common.schema.json#/$defs/runtimeDescriptor", + "description": "Exact runtime descriptor and capabilities observed at dispatch." + } + } + }, + "state": { + "enum": [ + "accepted", + "running", + "succeeded", + "failed", + "cancelled", + "timed_out", + "ambiguous" + ], + "description": "Run lifecycle per state-machines.json. accepted commits before any consequential side effect; terminal states never regress." + }, + "stateReason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "attemptCount": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Total attempts opened under this run, including retries. Monotonic." + }, + "currentAttemptId": { + "$ref": "common.schema.json#/$defs/attemptId", + "description": "Present while the run is accepted/running: the attempt that may still settle." + }, + "terminalDisposition": { + "type": "object", + "additionalProperties": false, + "required": ["outcome"], + "properties": { + "outcome": { + "enum": ["succeeded", "failed", "cancelled", "timed_out", "ambiguous"] + }, + "failureClass": { + "enum": [ + "launch_refused", + "runtime_error", + "timeout", + "cancelled_by_request", + "lease_expired", + "ambiguous_evidence" + ] + }, + "detail": { "type": "string", "maxLength": 2000 } + } + }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { + "status": { + "enum": ["none", "pending", "committed", "refused", "rolled_back"], + "description": "Output-target delivery state. committed may be recorded only after a completed, verified run (#816 atomic delivery rule)." + }, + "target": { "type": "string", "maxLength": 1024 }, + "artifactRefs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["ref"], + "properties": { + "ref": { "type": "string", "minLength": 1, "maxLength": 512 }, + "digest": { "$ref": "common.schema.json#/$defs/digest" } + } + }, + "maxItems": 64 + } + } + }, + "resultDigest": { "$ref": "common.schema.json#/$defs/digest" }, + "receiptRef": { + "$ref": "common.schema.json#/$defs/receiptId", + "description": "Receipt recording this run's terminal disposition, if one has been produced." + }, + "startedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "finishedAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "Required once the run reaches a terminal state; absent otherwise." + }, + "extensions": { "$ref": "common.schema.json#/$defs/extensionBag" } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { "enum": ["succeeded", "failed", "cancelled", "timed_out", "ambiguous"] } + } + }, + "then": { + "required": ["finishedAt", "terminalDisposition"] + } + } + ] +} diff --git a/spec/coven-automations/v1/capabilities.json b/spec/coven-automations/v1/capabilities.json new file mode 100644 index 00000000..d36027d8 --- /dev/null +++ b/spec/coven-automations/v1/capabilities.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "contractProfile": "coven.automations.v1", + "description": "Machine-readable variant negotiation for coven.automations.v1. A producer lists the trigger, condition, action, and policy variants it executes. A definition referencing a variant absent from `supported` (and, where explicitly flagged, `experimental`) MUST be refused with the typed error `CAPABILITY_UNSUPPORTED`; the error payload names the unsupported variant. This is the negative negotiation path: nothing about an unknown variant is guessed, defaulted, or silently downgraded.", + "supported": { + "triggers": [ + { + "variant": "schedule", + "profile": "coven.automations.v1", + "notes": "RRULE vocabulary per the #816 scheduler: FREQ=DAILY|WEEKLY, optional BYHOUR list, optional BYDAY list for weekly." + } + ], + "conditions": [], + "actions": [ + { + "variant": "familiarInvocation", + "profile": "coven.automations.v1", + "notes": "One prompt, one familiar, one runtime descriptor per definition; dispatch through the shared SessionLaunch path." + } + ], + "triggerPolicies": [ + { "variant": "misfire.latest", "notes": "On recovery only the latest missed slot runs; earlier slots collapse, never backfill." }, + { "variant": "overlap.forbid", "notes": "A run is skipped when the previous occurrence has not settled." }, + { "variant": "timeout.required", "notes": "Every definition carries a bounded per-run timeout, 1..=44640 minutes." } + ], + "deliveryPolicies": [ + { "variant": "outputTarget.atomic", "notes": "Optional atomic output path; commits only after a completed, verified run." } + ], + "retentionPolicies": [ + { "variant": "retention.standard" } + ] + }, + "experimental": [], + "refused": [ + { + "variant": "trigger.webhook", + "reason": "Not defined in v1; refuse with CAPABILITY_UNSUPPORTED rather than approximating with schedule." + }, + { + "variant": "action.pipeline", + "reason": "Multi-step pipelines are out of scope (issue non-goal: not a general-purpose workflow language)." + }, + { + "variant": "misfire.backfill", + "reason": "v1 misfire semantics collapse missed slots; backfill would change occurrence-key identity." + } + ], + "negotiationRules": [ + "Producers advertise this file (or a generated equivalent) on the capability surface; consumers read it before authoring definitions.", + "Refusal is per-variant and additive: refusing `trigger.webhook` says nothing about `action.familiarInvocation`.", + "Unknown policy values inside a supported variant are still unknown variants and fail closed.", + "A consumer must treat `experimental` entries as unavailable unless it explicitly opts in; opting in is a local decision and never changes wire semantics." + ] +} diff --git a/spec/coven-automations/v1/command-envelope.schema.json b/spec/coven-automations/v1/command-envelope.schema.json new file mode 100644 index 00000000..d3499b65 --- /dev/null +++ b/spec/coven-automations/v1/command-envelope.schema.json @@ -0,0 +1,362 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/command-envelope.schema.json", + "title": "Coven Automations v1 command and response envelopes", + "description": "Every mutating automations operation is one command envelope with a stable adoptionKey (idempotency), an expectedRevision where applicable, authenticated origin context, and explicit intent. Queries use the same envelope; only their semantics differ. A rejected or failed domain operation MUST produce outcome=rejected with a typed error — never an accepted wrapper around a failure. This document validates either a commandEnvelope or a commandResponse; consumers address each shape directly via the $defs pointer.", + "oneOf": [ + { "$ref": "#/$defs/commandEnvelope" }, + { "$ref": "#/$defs/commandResponse" } + ], + "$defs": { + "commandEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "command", "adoptionKey", "origin", "intent", "payload"], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "command": { "$ref": "#/$defs/commandName" }, + "adoptionKey": { "$ref": "common.schema.json#/$defs/adoptionKey" }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "description": "Required for definition-scoped mutating commands. The revision the caller believes is current; a mismatch commits nothing and returns REVISION_CONFLICT with the current revision." + }, + "origin": { + "type": "object", + "additionalProperties": false, + "required": ["principal", "channel"], + "properties": { + "principal": { "$ref": "common.schema.json#/$defs/principalRef" }, + "channel": { + "enum": ["daemon-ipc", "http", "control-action", "cli", "sdk", "cave"], + "description": "Authenticated transport the command arrived on. Transports that cannot authenticate the principal must be refused upstream; this field records, not decides." + }, + "authenticationClass": { "type": "string", "maxLength": 64 }, + "requestedAt": { "$ref": "common.schema.json#/$defs/timestamp" }, + "correlationId": { "$ref": "common.schema.json#/$defs/correlationId" } + } + }, + "intent": { + "type": "object", + "additionalProperties": false, + "required": ["statement"], + "properties": { + "statement": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Human-authored description of what this command intends. Recorded on events and receipts." + } + } + }, + "payload": { + "$comment": "Shape is pinned normatively by the per-command correlation branches under allOf below — the union of payload shapes is deliberately not expressed here because definition.create.v1 and definition.revise.v1 payloads are shape-identical and a union would double-match.", + "type": "object" + } + }, + "allOf": [ + { "if": { "properties": { "command": { "const": "definition.create.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionCreate" } } } }, + { "if": { "properties": { "command": { "const": "definition.revise.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionRevise" } } } }, + { "if": { "properties": { "command": { "const": "definition.activate.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionTarget" } } } }, + { "if": { "properties": { "command": { "const": "definition.pause.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionTarget" } } } }, + { "if": { "properties": { "command": { "const": "definition.disable.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionTarget" } } } }, + { "if": { "properties": { "command": { "const": "definition.tombstone.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionTarget" } } } }, + { "if": { "properties": { "command": { "const": "occurrence.runNow.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/runNow" } } } }, + { "if": { "properties": { "command": { "const": "occurrence.cancel.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/occurrenceCancel" } } } }, + { "if": { "properties": { "command": { "const": "run.cancel.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/runCancel" } } } }, + { "if": { "properties": { "command": { "const": "attempt.cancel.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/attemptCancel" } } } }, + { "if": { "properties": { "command": { "const": "attempt.retry.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/attemptRetry" } } } }, + { "if": { "properties": { "command": { "const": "occurrence.recover.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/occurrenceRecover" } } } }, + { "if": { "properties": { "command": { "const": "definition.list.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionList" } } } }, + { "if": { "properties": { "command": { "const": "definition.get.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionGet" } } } }, + { "if": { "properties": { "command": { "const": "run.history.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/runHistory" } } } }, + { "if": { "properties": { "command": { "const": "definition.health.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/definitionHealth" } } } }, + { "if": { "properties": { "command": { "const": "events.read.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/eventsRead" } } } }, + { "if": { "properties": { "command": { "const": "events.subscribe.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/eventsSubscribe" } } } }, + { "if": { "properties": { "command": { "const": "legacy.import.v1" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/legacyImport" } } } }, + { "if": { "properties": { "command": { "enum": ["definition.revise.v1", "definition.activate.v1", "definition.pause.v1", "definition.disable.v1", "definition.tombstone.v1"] } } }, "then": { "required": ["expectedRevision"] }, "else": { "not": { "required": ["expectedRevision"] } } } + ] + }, + "commandName": { + "enum": [ "definition.create.v1", + "definition.revise.v1", + "definition.activate.v1", + "definition.pause.v1", + "definition.disable.v1", + "definition.tombstone.v1", + "occurrence.runNow.v1", + "occurrence.cancel.v1", + "run.cancel.v1", + "attempt.cancel.v1", + "attempt.retry.v1", + "occurrence.recover.v1", + "definition.list.v1", + "definition.get.v1", + "run.history.v1", + "definition.health.v1", + "events.read.v1", + "events.subscribe.v1", + "legacy.import.v1" + ] + }, + "definitionCreate": { + "type": "object", + "additionalProperties": false, + "required": ["definition"], + "properties": { + "definition": { "$ref": "automation-definition.schema.json" } + } + }, + "definitionRevise": { + "type": "object", + "additionalProperties": false, + "required": ["definition"], + "properties": { + "definition": { + "$ref": "automation-definition.schema.json", + "description": "The full next-revision definition body. The command's expectedRevision is the revision being replaced; the accepted response carries revision = expectedRevision + 1." + } + } + }, + "definitionTarget": { + "type": "object", + "additionalProperties": false, + "required": ["automationId"], + "properties": { + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "reason": { "type": "string", "maxLength": 500 } + }, + "description": "Payload for activate, pause, disable, and tombstone." + }, + "runNow": { + "type": "object", + "additionalProperties": false, + "required": ["automationId"], + "properties": { + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "note": { "type": "string", "maxLength": 500 }, + "bypassEligibility": { + "type": "boolean", + "default": false, + "description": "When true the occurrence is planned and claimed immediately without condition evaluation; lifecycle state and policy (timeout, overlap) still apply." + } + } + }, + "occurrenceCancel": { + "type": "object", + "additionalProperties": false, + "required": ["occurrenceId"], + "properties": { + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "reason": { "type": "string", "maxLength": 500 } + } + }, + "runCancel": { + "type": "object", + "additionalProperties": false, + "required": ["runId"], + "properties": { + "runId": { "$ref": "common.schema.json#/$defs/runId" }, + "reason": { "type": "string", "maxLength": 500 } + } + }, + "attemptCancel": { + "type": "object", + "additionalProperties": false, + "required": ["attemptId"], + "properties": { + "attemptId": { "$ref": "common.schema.json#/$defs/attemptId" }, + "reason": { "type": "string", "maxLength": 500 } + } + }, + "attemptRetry": { + "type": "object", + "additionalProperties": false, + "required": ["runId", "priorAttemptNumber", "priorDisposition"], + "properties": { + "runId": { "$ref": "common.schema.json#/$defs/runId" }, + "priorAttemptNumber": { "type": "integer", "minimum": 1 }, + "priorDisposition": { + "enum": ["failed", "timed_out", "cancelled"], + "description": "Explicit prior disposition the retry is issued against. `ambiguous` is deliberately absent here: retrying ambiguous work requires occurrence.recover.v1 with an operator's explicit statement." + }, + "note": { "type": "string", "maxLength": 500 } + } + }, + "occurrenceRecover": { + "type": "object", + "additionalProperties": false, + "required": ["occurrenceId", "evidenceDetermination"], + "properties": { + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "evidenceDetermination": { + "enum": ["failed_deterministic", "retry_with_new_attempt"], + "description": "The operator's explicit determination of the ambiguous work. failed_deterministic settles the occurrence failed; retry_with_new_attempt opens a new attempt carrying the ambiguous prior disposition." + }, + "statement": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Operator's signed-off rationale; recorded on the event and any receipt." + } + } + }, + "definitionList": { + "type": "object", + "additionalProperties": false, + "properties": { + "lifecycleState": { + "enum": ["draft", "paused", "active", "disabled", "invalid", "tombstoned", "all"] + }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 }, + "cursor": { "type": "string", "maxLength": 256 } + } + }, + "definitionGet": { + "type": "object", + "additionalProperties": false, + "required": ["automationId"], + "properties": { + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Omit for the current revision; supply to read a historical revision, which is served immutable." + } + } + }, + "runHistory": { + "type": "object", + "additionalProperties": false, + "required": ["automationId"], + "properties": { + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, + "cursor": { "type": "string", "maxLength": 256 } + } + }, + "definitionHealth": { + "type": "object", + "additionalProperties": false, + "required": ["automationId"], + "properties": { + "automationId": { "$ref": "common.schema.json#/$defs/automationId" } + } + }, + "eventsRead": { + "type": "object", + "additionalProperties": false, + "required": ["stream"], + "properties": { + "stream": { "$ref": "#/$defs/streamRef" }, + "after": { + "$ref": "common.schema.json#/$defs/sequenceNumber", + "description": "Exclusive lower sequence bound. Omit with `from` to start from the stream beginning." + }, + "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 100 }, + "from": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "Alternative entry: replay from the first event recorded at or after this instant, resolving to a concrete cursor in the response." + } + } + }, + "eventsSubscribe": { + "type": "object", + "additionalProperties": false, + "required": ["stream"], + "properties": { + "stream": { "$ref": "#/$defs/streamRef" }, + "after": { "$ref": "common.schema.json#/$defs/sequenceNumber" }, + "checkpoint": { + "type": "string", + "maxLength": 512, + "description": "Opaque cursor from a prior read/subscribe response. Expired checkpoints yield CURSOR_EXPIRED with the expiry instant, never a silent rewind." + } + } + }, + "legacyImport": { + "type": "object", + "additionalProperties": false, + "required": ["source"], + "properties": { + "source": { + "enum": ["codex-automation-toml"], + "description": "v1 supports the #816 non-destructive Codex import; imported definitions are created PAUSED (draft in v1 lifecycle terms) with provenance.importedFrom set." + }, + "dryRun": { + "type": "boolean", + "default": false, + "description": "Report what would import, skip, and fail without writing." + } + } + }, + "streamRef": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id"], + "properties": { + "kind": { + "enum": ["automation", "occurrence", "run", "feed"] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 320, + "description": "automationId / occurrenceId / runId; empty string for the global feed." + } + } + }, + "commandResponse": { + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "command", "adoptionKey", "outcome"], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "command": { "$ref": "#/$defs/commandName" }, + "adoptionKey": { "$ref": "common.schema.json#/$defs/adoptionKey" }, + "outcome": { + "enum": ["committed", "replayed", "rejected"], + "description": "committed: first time this adoptionKey committed. replayed: this exact adoptionKey committed before; the original committed result is returned unchanged and idempotent. rejected: nothing committed; see error. There is no accepted-but-failed shape: a domain failure is always outcome=rejected." + }, + "replay": { + "type": "object", + "additionalProperties": false, + "required": ["firstCommittedAt"], + "properties": { + "firstCommittedAt": { "$ref": "common.schema.json#/$defs/timestamp" } + }, + "description": "Present if and only if outcome=replayed." + }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Definition revision after a committed or replayed definition-scoped mutation." + }, + "result": { + "type": "object", + "additionalProperties": true, + "description": "Command-specific committed result: definition/occurrence/run/attempt projections, health snapshot, event page, or import report. Present only for committed/replayed outcomes." + }, + "error": { + "$ref": "error-envelope.schema.json#/$defs/errorEnvelope", + "description": "Present if and only if outcome=rejected." + }, + "receiptRef": { + "$ref": "common.schema.json#/$defs/receiptId", + "description": "Receipt produced by this command, when one exists." + }, + "eventRef": { + "type": "object", + "additionalProperties": false, + "required": ["stream", "sequence"], + "properties": { + "stream": { "type": "string", "minLength": 1, "maxLength": 400 }, + "sequence": { "$ref": "common.schema.json#/$defs/sequenceNumber" } + }, + "description": "Position of the event this command appended, for clients that follow the changefeed." + } + } + } + } +} diff --git a/spec/coven-automations/v1/common.schema.json b/spec/coven-automations/v1/common.schema.json new file mode 100644 index 00000000..e827452a --- /dev/null +++ b/spec/coven-automations/v1/common.schema.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/common.schema.json", + "title": "Coven Automations v1 shared definitions", + "description": "Shared value definitions for coven.automations.v1. Schemas in this directory reference these via relative $ref. Unknown-field rule: v1 objects set additionalProperties=false and carry optional data only through the explicit extensionBag.", + "$defs": { + "schemaVersion": { + "type": "string", + "const": "coven.automations.v1" + }, + "automationId": { + "type": "string", + "minLength": 1, + "maxLength": 96, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "description": "Stable automation identifier; charset and bound match the #816 definition validator." + }, + "occurrenceId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "runId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "attemptId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "receiptId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "adoptionKey": { + "type": "string", + "minLength": 8, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$", + "description": "Stable request/adoption (idempotency) key supplied by the caller. Replays of the same command with the same key return the first committed outcome unchanged." + }, + "correlationId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "timestamp": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{3})?Z$", + "description": "RFC 3339 UTC, millisecond precision, matching the #816 store encoding (chrono SecondsFormat::Millis)." + }, + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "canonicalization", "value"], + "properties": { + "algorithm": { "const": "sha256" }, + "canonicalization": { "const": "jcs-rfc8785" }, + "value": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Lowercase hex SHA-256 over the RFC 8785 canonical serialization of the covered object." + } + } + }, + "fenceToken": { + "type": "integer", + "minimum": 1, + "description": "Monotonic per-occurrence generation. Incremented on every accepted claim; a fence token owns at most one accepted run." + }, + "sequenceNumber": { + "type": "integer", + "minimum": 0 + }, + "principalRef": { + "type": "object", + "additionalProperties": false, + "required": ["principalId"], + "properties": { + "principalId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@-]*$", + "description": "Canonical principal identifier from the authority layer. This protocol binds but does not define principal semantics." + }, + "displayName": { "type": "string", "maxLength": 160 } + } + }, + "familiarRef": { + "type": "object", + "additionalProperties": false, + "required": ["familiarId"], + "properties": { + "familiarId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Exact familiar identity binding; resolved and owned by the familiar identity layer." + } + } + }, + "runtimeDescriptor": { + "type": "object", + "additionalProperties": false, + "required": ["runtimeId", "capabilities"], + "properties": { + "runtimeId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Harness/runtime identifier, for example coven-code." + }, + "capabilities": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 96 }, + "uniqueItems": true, + "description": "Capability requirement keys the run depends on; advertised by the runtime descriptor layer." + }, + "model": { "type": "string", "maxLength": 128 } + } + }, + "approvalRef": { + "type": "object", + "additionalProperties": false, + "required": ["approvalPolicyRef"], + "properties": { + "approvalPolicyRef": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Reference into the authority/approval policy layer; this protocol records the binding, never the decision." + }, + "approvalRecordRef": { "type": "string", "maxLength": 200 } + } + }, + "extensionBag": { + "type": "object", + "additionalProperties": { "$comment": "Extension values are opaque JSON; producers must keep them small and consumers must preserve but never interpret them until understood.", "type": ["object", "string", "number", "boolean", "array", "null"] }, + "propertyNames": { + "pattern": "^(x-[a-z0-9-]+|[a-z0-9.-]+\\.[a-z0-9-]+\\.[a-z0-9-]+)$", + "description": "Keys are either x-prefixed (vendor-local) or reverse-DNS namespaced (example com.acme.lift). Untyped keys are rejected." + }, + "description": "Explicit extension bag. Extensions are preserved on round-trip, never required, never influence state, digests, or authorization until they are promoted into a new contract profile." + }, + "misfireDisposition": { + "enum": ["none", "collapsed_to_latest", "skipped_overlap", "skipped_paused", "skipped_invalid"], + "description": "How planning disposed of this slot relative to the misfire policy. collapsed_to_latest is the #816 misfire-latest semantic." + }, + "privacyClassification": { + "enum": ["public", "operational", "sensitive", "restricted"] + }, + "retentionClass": { + "type": "object", + "additionalProperties": false, + "required": ["classification"], + "properties": { + "classification": { "enum": ["ephemeral", "standard", "extended"] }, + "deleteAfter": { "$ref": "#/$defs/timestamp" } + } + }, + "producerIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["component", "instanceId"], + "properties": { + "component": { + "type": "string", + "minLength": 1, + "maxLength": 96, + "description": "Producing component, for example coven-daemon." + }, + "instanceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Stable instance identifier of the producer." + }, + "implementationVersion": { + "type": "string", + "maxLength": 64, + "description": "Release version of the producing implementation. Never confused with the contract profile." + } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["createdBy"], + "properties": { + "createdBy": { "$ref": "#/$defs/principalRef" }, + "createdAt": { "$ref": "#/$defs/timestamp" }, + "updatedBy": { "$ref": "#/$defs/principalRef" }, + "updatedAt": { "$ref": "#/$defs/timestamp" }, + "importedFrom": { + "type": "string", + "maxLength": 200, + "description": "Legacy source marker for imported definitions, for example codex.automation.toml." + } + } + }, + "activationWindow": { + "type": "object", + "additionalProperties": false, + "properties": { + "effectiveFrom": { "$ref": "#/$defs/timestamp" }, + "effectiveUntil": { "$ref": "#/$defs/timestamp" } + }, + "description": "Optional activation window. Outside the window the definition behaves as paused without changing lifecycle state." + } + } +} diff --git a/spec/coven-automations/v1/compatibility-matrix.json b/spec/coven-automations/v1/compatibility-matrix.json new file mode 100644 index 00000000..d485ed39 --- /dev/null +++ b/spec/coven-automations/v1/compatibility-matrix.json @@ -0,0 +1,100 @@ +{ + "contractProfile": "coven.automations.v1", + "version": 1, + "notes": [ + "Machine-readable compatibility matrix for coven.automations.v1.", + "changeClass defines what a future change to any field or variant is, and what a v1 consumer/producer must do when it meets it.", + "Consumers and producers must refuse incompatible profiles explicitly with SCHEMA_VERSION_UNSUPPORTED; they never approximate or degrade silently." + ], + "changeClasses": [ + { + "id": "additive-variant", + "definition": "A new trigger/condition/action variant or policy value is added to a union.", + "v1ConsumerBehavior": "fail-closed per variant: refuse with CAPABILITY_UNSUPPORTED naming the variant. Unions are open in future profiles, closed in v1 readers.", + "profileImpact": "none; same major profile, consumers negotiate via capabilities.json" + }, + { + "id": "additive-extension", + "definition": "A new key inside `extensions`.", + "v1ConsumerBehavior": "preserve, never interpret; round-trip verbatim.", + "profileImpact": "none" + }, + { + "id": "additive-optional-field", + "definition": "A new optional top-level or nested field is introduced.", + "v1ConsumerBehavior": "v1 readers reject unknown fields (additionalProperties=false). Producers introduce such fields only under a new minor profile (for example coven.automations.v1.1) and dual-emit while consumers migrate.", + "profileImpact": "minor profile bump" + }, + { + "id": "incompatible-field-change", + "definition": "A field changes meaning, type, requirement, or is removed/renamed.", + "v1ConsumerBehavior": "refuse the profile explicitly with SCHEMA_VERSION_UNSUPPORTED.", + "profileImpact": "new major profile (coven.automations.v2); v1 is frozen" + }, + { + "id": "state-machine-change", + "definition": "A state, transition, guard, or invariant in state-machines.json changes.", + "v1ConsumerBehavior": "refuse; lifecycle semantics are the contract's core and are never evolved additively.", + "profileImpact": "new major profile" + } + ], + "objectFields": [ + { "object": "AutomationDefinition", "field": "schemaVersion", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationDefinition", "field": "automationId", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationDefinition", "field": "revision", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationDefinition", "field": "integrity", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationDefinition", "field": "lifecycleState", "status": "required-since-v1", "changeClass": "state-machine-change" }, + { "object": "AutomationDefinition", "field": "deletion", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationDefinition", "field": "display", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationDefinition", "field": "trigger", "status": "required-since-v1", "changeClass": "additive-variant" }, + { "object": "AutomationDefinition", "field": "conditions", "status": "optional-since-v1", "changeClass": "additive-variant" }, + { "object": "AutomationDefinition", "field": "action", "status": "required-since-v1", "changeClass": "additive-variant" }, + { "object": "AutomationDefinition", "field": "binding", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationDefinition", "field": "runtimeRequirements", "status": "required-for-active", "changeClass": "additive-optional-field" }, + { "object": "AutomationDefinition", "field": "policies", "status": "required-since-v1", "changeClass": "additive-variant" }, + { "object": "AutomationDefinition", "field": "provenance", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationDefinition", "field": "activation", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationDefinition", "field": "extensions", "status": "optional-since-v1", "changeClass": "additive-extension" }, + { "object": "AutomationOccurrence", "field": "occurrenceKey", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationOccurrence", "field": "automationRevision", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationOccurrence", "field": "state", "status": "required-since-v1", "changeClass": "state-machine-change" }, + { "object": "AutomationOccurrence", "field": "fence", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationOccurrence", "field": "misfireDisposition", "status": "optional-since-v1", "changeClass": "additive-variant" }, + { "object": "AutomationOccurrence", "field": "activeRunRef", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationOccurrence", "field": "cancellation", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationOccurrence", "field": "recovery", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationOccurrence", "field": "eventWindow", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationRun", "field": "binding", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationRun", "field": "state", "status": "required-since-v1", "changeClass": "state-machine-change" }, + { "object": "AutomationRun", "field": "attemptCount", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationRun", "field": "terminalDisposition", "status": "required-when-terminal", "changeClass": "additive-optional-field" }, + { "object": "AutomationRun", "field": "delivery", "status": "optional-since-v1", "changeClass": "additive-variant" }, + { "object": "AutomationRun", "field": "receiptRef", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationAttempt", "field": "attemptNumber", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationAttempt", "field": "adoptionKey", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationAttempt", "field": "dispatchFence", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationAttempt", "field": "priorDisposition", "status": "required-for-retries", "changeClass": "additive-optional-field" }, + { "object": "AutomationAttempt", "field": "workerCorrelation", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationAttempt", "field": "leaseObservations", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationAttempt", "field": "outputCursors", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "AutomationAttempt", "field": "state", "status": "required-since-v1", "changeClass": "state-machine-change" }, + { "object": "AutomationReceipt", "field": "receiptId", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationReceipt", "field": "integrity", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "AutomationReceipt", "field": "sideEffectClass", "status": "required-since-v1", "changeClass": "additive-variant" }, + { "object": "AutomationReceipt", "field": "privacy", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "EventEnvelope", "field": "eventId", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "EventEnvelope", "field": "stream", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "EventEnvelope", "field": "sequence", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "EventEnvelope", "field": "kind", "status": "required-since-v1", "changeClass": "additive-variant" }, + { "object": "EventEnvelope", "field": "causation", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "EventEnvelope", "field": "integrity", "status": "optional-since-v1", "changeClass": "additive-optional-field" }, + { "object": "CommandEnvelope", "field": "adoptionKey", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "CommandEnvelope", "field": "expectedRevision", "status": "required-for-definition-mutations", "changeClass": "incompatible-field-change" }, + { "object": "CommandEnvelope", "field": "origin", "status": "required-since-v1", "changeClass": "incompatible-field-change" }, + { "object": "CommandEnvelope", "field": "intent", "status": "required-since-v1", "changeClass": "incompatible-field-change" } + ], + "profileRefusal": { + "rule": "A consumer that receives a schemaVersion it does not implement refuses the object with SCHEMA_VERSION_UNSUPPORTED and stops; it must not skip unknown versions, must not coerce, and must not reinterpret.", + "negotiation": "Producers advertise implemented profiles via protocol-version.json; callers pin the profile at session start." + } +} diff --git a/spec/coven-automations/v1/conformance-manifest.json b/spec/coven-automations/v1/conformance-manifest.json new file mode 100644 index 00000000..adb1c56b --- /dev/null +++ b/spec/coven-automations/v1/conformance-manifest.json @@ -0,0 +1,47 @@ +{ + "protocol": "Coven Automations", + "contractProfile": "coven.automations.v1", + "objects": [ + "AutomationDefinition", + "AutomationOccurrence", + "AutomationRun", + "AutomationAttempt", + "AutomationReceipt", + "CommandEnvelope", + "CommandResponse", + "ErrorEnvelope", + "EventEnvelope" + ], + "schemas": [ + "common.schema.json", + "automation-definition.schema.json", + "automation-occurrence.schema.json", + "automation-run.schema.json", + "automation-attempt.schema.json", + "automation-receipt.schema.json", + "command-envelope.schema.json", + "error-envelope.schema.json", + "event-envelope.schema.json" + ], + "stateMachines": "state-machines.json", + "compatibilityMatrix": "compatibility-matrix.json", + "goldenVectors": "test-vectors.json", + "requiredSuites": [ + "schema-validation", + "rust-round-trip", + "state-machine-property-tests", + "request-adoption-replay-and-conflict", + "expected-revision-conflict", + "duplicate-and-out-of-order-event-replay", + "typed-transport-domain-error-mapping", + "golden-vectors-external-runners", + "packed-artifact-canaries" + ], + "releaseState": "proposed", + "productionReady": false, + "canaryRequirements": { + "coven": "Consumes the schemas and vectors from this directory through a packed/released artifact, not source-relative imports.", + "sdk": "Consumes the pinned TypeScript declarations and golden vectors against the exact released artifact version.", + "cave": "Consumes the event envelope and read-model reducer vectors against the exact released artifact version." + } +} diff --git a/spec/coven-automations/v1/coven.automations.v1.d.ts b/spec/coven-automations/v1/coven.automations.v1.d.ts new file mode 100644 index 00000000..11926e66 --- /dev/null +++ b/spec/coven-automations/v1/coven.automations.v1.d.ts @@ -0,0 +1,619 @@ +/** + * coven.automations.v1 — pinned TypeScript contract types. + * + * SPEC ARTIFACT, NOT A PACKAGE MODULE. This file is the portable, + * hand-pinned type projection of the JSON Schemas in this directory + * (draft 2020-12). SDK and Cave canaries consume it as a pinned artifact + * — never as a source-relative import of Coven internals — and must pair + * it with test-vectors.json for behavioral conformance. + * + * Regeneration: any change here requires a contract-profile decision per + * compatibility-matrix.json. Field shapes mirror the schemas exactly; + * descriptions are omitted here and live in the schemas. + * + * Consumers must treat unknown variants (trigger, action, condition, + * policy values, event kinds) as failures per the negative negotiation + * rules in capabilities.json — TS unions here are closed. + */ + +export type SchemaVersion = "coven.automations.v1"; + +/** RFC 3339 UTC, millisecond precision (matches the #816 store encoding). */ +export type Timestamp = string; + +export interface Digest { + algorithm: "sha256"; + canonicalization: "jcs-rfc8785"; + /** Lowercase hex SHA-256 over the RFC 8785 canonical serialization of the covered object. */ + value: string; +} + +export type AdoptionKey = string; +export type CorrelationId = string; + +export interface PrincipalRef { + principalId: string; + displayName?: string; +} + +export interface FamiliarRef { + familiarId: string; +} + +export interface RuntimeDescriptor { + runtimeId: string; + capabilities: string[]; + model?: string; +} + +export interface ApprovalRef { + approvalPolicyRef: string; + approvalRecordRef?: string; +} + +/** Keys are `x-prefixed` or reverse-DNS namespaced; values opaque; preserve, never interpret. */ +export type ExtensionBag = Record; + +export type PrivacyClassification = "public" | "operational" | "sensitive" | "restricted"; + +export interface RetentionClass { + classification: "ephemeral" | "standard" | "extended"; + deleteAfter?: Timestamp; +} + +export interface ProducerIdentity { + component: string; + instanceId: string; + implementationVersion?: string; +} + +export interface Provenance { + createdBy: PrincipalRef; + createdAt?: Timestamp; + updatedBy?: PrincipalRef; + updatedAt?: Timestamp; + importedFrom?: string; +} + +// --------------------------------------------------------------------------- +// AutomationDefinition +// --------------------------------------------------------------------------- + +export type DefinitionLifecycleState = "draft" | "paused" | "active" | "disabled" | "invalid"; + +export interface AutomationDefinition { + schemaVersion: SchemaVersion; + automationId: string; + /** Monotonic per automation; incremented by exactly one per accepted mutation. */ + revision: number; + integrity: Digest; + lifecycleState: DefinitionLifecycleState; + deletion?: { + tombstoned: true; + requestedAt: Timestamp; + requestedBy?: PrincipalRef; + reason?: string; + }; + display: { + name: string; + description?: string; + tags?: string[]; + }; + trigger: ScheduleTrigger; // v1 union: exactly this variant; future variants extend the union in later profiles + conditions?: Condition[]; // v1 defines zero condition variants; any value fails validation + action: FamiliarInvocationAction; // v1 union: exactly this variant + binding: { + familiarBindingPolicy: "exact"; + familiarId: string; + authority: ApprovalRef; + }; + runtimeRequirements?: RuntimeDescriptor; // required for active/paused/disabled + policies: { + timeout: { perRunMinutes: number }; // 1..=44640 + retry: { + maxAttempts: number; // 1..=10 + backoffPolicy: "none" | "fixed" | "exponential"; + backoffSeconds?: number; // required when backoffPolicy is "fixed" + retryableClasses?: Array<"transient_dispatch" | "lease_expired" | "runtime_unavailable">; + }; + concurrency: { overlap: "forbid" }; + misfire: { disposition: "latest" }; + delivery?: { + outputTarget: string; + mode?: "atomic"; // required when outputTarget is present + }; + retention: { + occurrenceHistory: RetentionClass; + runLogs?: RetentionClass; + receipts?: RetentionClass; + }; + }; + provenance?: Provenance; + activation?: { + effectiveFrom?: Timestamp; + effectiveUntil?: Timestamp; + }; + extensions?: ExtensionBag; +} + +export interface ScheduleTrigger { + variant: "schedule"; + version: 1; + schedule: { + /** Scoped RRULE: FREQ=DAILY|WEEKLY, optional BYHOUR list, optional BYDAY list for weekly. */ + rrule: string; + timezone: "local" | "utc"; + }; +} + +/** v1 has zero condition variants; this type is intentionally uninhabited. */ +export type Condition = never; + +export interface FamiliarInvocationAction { + variant: "familiarInvocation"; + version: 1; + prompt: string; + cwd?: string; +} + +// --------------------------------------------------------------------------- +// AutomationOccurrence / AutomationRun / AutomationAttempt / AutomationReceipt +// --------------------------------------------------------------------------- + +export type OccurrenceState = + | "planned" + | "eligible" + | "claimed" + | "dispatching" + | "running" + | "recovering" + | "recovery_required" + | "succeeded" + | "failed" + | "cancelled" + | "timed_out" + | "skipped" + | "superseded"; + +export type MisfireDisposition = + | "none" + | "collapsed_to_latest" + | "skipped_overlap" + | "skipped_paused" + | "skipped_invalid"; + +export interface AutomationOccurrence { + schemaVersion: SchemaVersion; + occurrenceId: string; + automationId: string; + /** Exact definition revision executed against; never rewritten. */ + automationRevision: number; + triggerIdentity: + | { kind: "schedule.slot"; rruleRef: string } + | { kind: "manual.request"; requestedBy?: PrincipalRef }; + /** `automationId@scheduledFor` or `automationId@manual-`. */ + occurrenceKey: string; + scheduledFor: Timestamp; + observedAt?: Timestamp; + eligibleAt?: Timestamp; + state: OccurrenceState; + stateReason: string; + fence: { + generation: number; // monotonic, >= 1 + claimedBy?: string; + leaseExpiresAt?: Timestamp; + }; + misfireDisposition?: MisfireDisposition; + claimMetadata?: { + claimedAt: Timestamp; + leaseMinutes: number; // 1..=1440 + }; + activeRunRef?: string; + cancellation?: { + requestedAt: Timestamp; + requestedBy?: PrincipalRef; + acknowledgedAt?: Timestamp; + reconciledAt?: Timestamp; + }; + recovery?: { + enteredAt: Timestamp; + evidence?: "lease_expired" | "dispatch_unconfirmed" | "runtime_lost"; + resolvedDisposition?: "failed_deterministic" | "failed_ambiguous"; + }; + createdAt?: Timestamp; + updatedAt?: Timestamp; + eventWindow?: { + firstSequence: number; + lastSequence: number; + }; + extensions?: ExtensionBag; +} + +export type RunState = + | "accepted" + | "running" + | "succeeded" + | "failed" + | "cancelled" + | "timed_out" + | "ambiguous"; + +export type RunOutcome = + | "succeeded" + | "failed" + | "cancelled" + | "timed_out" + | "ambiguous"; + +export interface AutomationRun { + schemaVersion: SchemaVersion; + runId: string; + occurrenceId: string; + automationId: string; + automationRevision: number; + binding: { + familiar: FamiliarRef; + authority: { + principal: PrincipalRef; + approval?: ApprovalRef; + authenticationClass?: string; + }; + runtime: RuntimeDescriptor; + }; + state: RunState; + stateReason?: string; + attemptCount: number; + currentAttemptId?: string; + terminalDisposition?: { + outcome: RunOutcome; + failureClass?: + | "launch_refused" + | "runtime_error" + | "timeout" + | "cancelled_by_request" + | "lease_expired" + | "ambiguous_evidence"; + detail?: string; + }; + delivery?: { + status: "none" | "pending" | "committed" | "refused" | "rolled_back"; + target?: string; + artifactRefs?: Array<{ ref: string; digest?: Digest }>; + }; + resultDigest?: Digest; + receiptRef?: string; + startedAt: Timestamp; + finishedAt?: Timestamp; // required when state is terminal + extensions?: ExtensionBag; +} + +export type AttemptState = + | "adopted" + | "dispatching" + | "started" + | "observing" + | "succeeded" + | "failed" + | "cancelled" + | "timed_out" + | "ambiguous"; + +export interface AutomationAttempt { + schemaVersion: SchemaVersion; + attemptId: string; + runId: string; + occurrenceId: string; + /** Monotonic within the run, starting at 1; never reused. */ + attemptNumber: number; + adoptionKey: AdoptionKey; + priorDisposition?: { + attemptNumber: number; + outcome: "failed" | "timed_out" | "ambiguous" | "cancelled"; + }; + dispatchFence: { + occurrenceFenceGeneration: number; + dispatchGeneration: number; + }; + workerCorrelation?: { + workerId: string; + sessionId?: string; // at most one session binds to one attempt + adoptedAt?: Timestamp; + }; + retryClassification?: { + classification?: "initial" | "automatic_retry" | "operator_retry" | "operator_recovery"; + eligibleClasses?: Array<"transient_dispatch" | "lease_expired" | "runtime_unavailable">; + }; + leaseObservations?: Array<{ + observedAt: Timestamp; + heartbeatOk: boolean; + note?: string; + }>; + outputCursors?: { + eventCursor?: number; + logCursor?: number; + }; + state: AttemptState; + stateReason?: string; + openedAt?: Timestamp; + settledAt?: Timestamp; + extensions?: ExtensionBag; +} + +export type SideEffectClass = + | "none" + | "local_read" + | "local_write" + | "external_read" + | "external_mutation" + | "irreversible_external_mutation"; + +export interface AutomationReceipt { + schemaVersion: SchemaVersion; + receiptId: string; + automationId: string; + automationRevision: number; + definitionDigest: Digest; + occurrenceId: string; + occurrenceFenceGeneration: number; + runId: string; + attemptId: string; + attemptNumber: number; + identity: FamiliarRef; + authority?: { + principal: PrincipalRef; + approval?: ApprovalRef; + }; + runtime: RuntimeDescriptor; + deliveryDigest?: Digest; + resultDigest?: Digest; + exercisedCapabilities?: string[]; + sideEffectClass: SideEffectClass; + outcome: { + disposition: RunOutcome; + failureClass?: string; + detail?: string; + partialFailures?: Array<{ + step: string; + reason: string; + recovered?: boolean; + }>; + recoveryDisposition?: "not_required" | "recovered_inline" | "deferred_to_operator"; + }; + producedAt: Timestamp; + producer: ProducerIdentity; + integrity: Digest & { + authentication: "none" | "producer-hmac" | "cosign"; + }; + privacy: { + classification: PrivacyClassification; + retention: RetentionClass; + notes?: string; + }; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +export type CommandName = + | "definition.create.v1" + | "definition.revise.v1" + | "definition.activate.v1" + | "definition.pause.v1" + | "definition.disable.v1" + | "definition.tombstone.v1" + | "occurrence.runNow.v1" + | "occurrence.cancel.v1" + | "run.cancel.v1" + | "attempt.cancel.v1" + | "attempt.retry.v1" + | "occurrence.recover.v1" + | "definition.list.v1" + | "definition.get.v1" + | "run.history.v1" + | "definition.health.v1" + | "events.read.v1" + | "events.subscribe.v1" + | "legacy.import.v1"; + +export interface CommandOrigin { + principal: PrincipalRef; + channel: "daemon-ipc" | "http" | "control-action" | "cli" | "sdk" | "cave"; + authenticationClass?: string; + requestedAt?: Timestamp; + correlationId?: CorrelationId; +} + +/** Payload shapes per command; see command-envelope.schema.json for the normative pin. */ +export interface CommandPayloadByCommand { + "definition.create.v1": { definition: AutomationDefinition }; + "definition.revise.v1": { definition: AutomationDefinition }; + "definition.activate.v1": { automationId: string; reason?: string }; + "definition.pause.v1": { automationId: string; reason?: string }; + "definition.disable.v1": { automationId: string; reason?: string }; + "definition.tombstone.v1": { automationId: string; reason?: string }; + "occurrence.runNow.v1": { automationId: string; note?: string; bypassEligibility?: boolean }; + "occurrence.cancel.v1": { occurrenceId: string; reason?: string }; + "run.cancel.v1": { runId: string; reason?: string }; + "attempt.cancel.v1": { attemptId: string; reason?: string }; + "attempt.retry.v1": { + runId: string; + priorAttemptNumber: number; + priorDisposition: "failed" | "timed_out" | "cancelled"; + note?: string; + }; + "occurrence.recover.v1": { + occurrenceId: string; + evidenceDetermination: "failed_deterministic" | "retry_with_new_attempt"; + statement: string; + }; + "definition.list.v1": { + lifecycleState?: "draft" | "paused" | "active" | "disabled" | "invalid" | "tombstoned" | "all"; + limit?: number; + cursor?: string; + }; + "definition.get.v1": { automationId: string; revision?: number }; + "run.history.v1": { + automationId: string; + occurrenceId?: string; + limit?: number; + cursor?: string; + }; + "definition.health.v1": { automationId: string }; + "events.read.v1": { + stream: StreamRef; + after?: number; + limit?: number; + from?: Timestamp; + }; + "events.subscribe.v1": { + stream: StreamRef; + after?: number; + checkpoint?: string; + }; + "legacy.import.v1": { source: "codex-automation-toml"; dryRun?: boolean }; +} + +export interface StreamRef { + kind: "automation" | "occurrence" | "run" | "feed"; + id: string; +} + +export interface CommandEnvelope { + schemaVersion: SchemaVersion; + command: C; + adoptionKey: AdoptionKey; + /** Required for definition.revise/activate/pause/disable/tombstone; forbidden otherwise. */ + expectedRevision?: number; + origin: CommandOrigin; + intent: { statement: string }; + payload: CommandPayloadByCommand[C]; +} + +export type ErrorCode = + | "SCHEMA_VERSION_UNSUPPORTED" + | "VALIDATION_FAILED" + | "ADOPTION_REPLAY_MISMATCH" + | "REVISION_CONFLICT" + | "NOT_FOUND" + | "GONE_TOMBSTONED" + | "CAPABILITY_UNSUPPORTED" + | "ILLEGAL_TRANSITION" + | "AUTHORITY_REQUIRED" + | "APPROVAL_REQUIRED" + | "CANCEL_PENDING" + | "OVERLAP_FORBIDDEN" + | "RETRY_DISPOSITION_INVALID" + | "AMBIGUOUS_RETRY_FORBIDDEN" + | "CURSOR_EXPIRED" + | "STREAM_OUT_OF_ORDER" + | "PAYLOAD_TOO_LARGE" + | "DEADLINE_EXCEEDED" + | "CONCURRENCY_LIMIT" + | "INTERNAL"; + +export interface ErrorEnvelope { + code: ErrorCode; + httpStatus: number; + message: string; + retryable: boolean; + details?: Record; + adoption?: { + key: AdoptionKey; + conflictOutcome?: "committed" | "rejected"; + }; + currentRevision?: number; +} + +export interface CommandResponse { + schemaVersion: SchemaVersion; + command: C; + adoptionKey: AdoptionKey; + outcome: "committed" | "replayed" | "rejected"; + replay?: { firstCommittedAt: Timestamp }; + revision?: number; + result?: Record; + error?: ErrorEnvelope; + receiptRef?: string; + eventRef?: { stream: string; sequence: number }; +} + +// --------------------------------------------------------------------------- +// Events / changefeed +// --------------------------------------------------------------------------- + +export type EventKind = + | "definition.created" + | "definition.revised" + | "definition.activated" + | "definition.paused" + | "definition.disabled" + | "definition.invalidated" + | "definition.tombstoned" + | "definition.imported" + | "occurrence.transitioned" + | "occurrence.misfire_recorded" + | "run.transitioned" + | "attempt.transitioned" + | "receipt.recorded" + | "feed.snapshot"; + +export type EventPayload = + | { + revision: number; + definitionDigest?: Digest; + lifecycleState?: DefinitionLifecycleState | "tombstoned"; + importedFrom?: string; + } + | { + entity: "occurrence" | "run" | "attempt"; + from: string; + to: string; + reason: string; + fenceGeneration?: number; + attemptNumber?: number; + commandAdoptionKey?: AdoptionKey; + } + | { + disposition: MisfireDisposition; + collapsedSlots: Timestamp[]; + } + | { + receiptRef: string; + outcome: RunOutcome; + sideEffectClass?: SideEffectClass; + } + | { + throughSequence: number; + state: Record; + reason?: "retention_compaction" | "manual_snapshot"; + }; + +export interface EventEnvelope { + schemaVersion: SchemaVersion; + /** Globally unique; duplicates of a delivered eventId are redeliveries: ignore, never re-apply. */ + eventId: string; + stream: { kind: "automation" | "occurrence" | "run" | "feed"; id: string }; + /** Monotonically increasing, gapless within `stream`. */ + sequence: number; + recordedAt: Timestamp; + observedAt: Timestamp; + producer: ProducerIdentity; + causation?: { + adoptionKey?: AdoptionKey; + causeEventId?: string; + correlationId?: CorrelationId; + }; + automationId?: string; + occurrenceId?: string; + runId?: string; + attemptId?: string; + kind: EventKind; + summary: string; + payload: EventPayload; + privacy: { + classification: PrivacyClassification; + retention: RetentionClass; + }; + integrity?: Digest; +} diff --git a/spec/coven-automations/v1/error-envelope.schema.json b/spec/coven-automations/v1/error-envelope.schema.json new file mode 100644 index 00000000..a6318582 --- /dev/null +++ b/spec/coven-automations/v1/error-envelope.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/error-envelope.schema.json", + "title": "Coven Automations v1 typed error envelope", + "description": "Typed errors for every automations failure, with the HTTP and control-action status mapping. Domain failures are errors — they are never wrapped in an accepted/completed outer response merely because routing succeeded. The document root validates an errorEnvelope; the frozen statusMapping remains addressable at #/$defs/statusMapping.", + "$ref": "#/$defs/errorEnvelope", + "$defs": { + "errorCode": { + "enum": [ + "SCHEMA_VERSION_UNSUPPORTED", + "VALIDATION_FAILED", + "ADOPTION_REPLAY_MISMATCH", + "REVISION_CONFLICT", + "NOT_FOUND", + "GONE_TOMBSTONED", + "CAPABILITY_UNSUPPORTED", + "ILLEGAL_TRANSITION", + "AUTHORITY_REQUIRED", + "APPROVAL_REQUIRED", + "CANCEL_PENDING", + "OVERLAP_FORBIDDEN", + "RETRY_DISPOSITION_INVALID", + "AMBIGUOUS_RETRY_FORBIDDEN", + "CURSOR_EXPIRED", + "STREAM_OUT_OF_ORDER", + "PAYLOAD_TOO_LARGE", + "DEADLINE_EXCEEDED", + "CONCURRENCY_LIMIT", + "INTERNAL" + ] + }, + "errorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["code", "httpStatus", "message", "retryable"], + "properties": { + "code": { "$ref": "#/$defs/errorCode" }, + "httpStatus": { + "type": "integer", + "minimum": 400, + "maximum": 599, + "description": "Canonical HTTP status for this error class when surfaced over HTTP or a control-action response." + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Human-readable, safe-to-display description." + }, + "retryable": { + "type": "boolean", + "description": "Whether an identical retry (same adoptionKey for mutations) could succeed after delay." + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Structured specifics: the failing JSON-Schema path for VALIDATION_FAILED, the current revision for REVISION_CONFLICT, the unsupported variant for CAPABILITY_UNSUPPORTED, the expired instant for CURSOR_EXPIRED, and so on." + }, + "adoption": { + "type": "object", + "additionalProperties": false, + "required": ["key"], + "properties": { + "key": { "$ref": "common.schema.json#/$defs/adoptionKey" }, + "conflictOutcome": { + "enum": ["committed", "rejected"], + "description": "For ADOPTION_REPLAY_MISMATCH: what the key actually committed, so callers can reconcile instead of guessing." + } + }, + "description": "Present when the error concerns the caller's adoption key." + }, + "currentRevision": { + "type": "integer", + "minimum": 1, + "description": "For REVISION_CONFLICT: the revision the caller must re-read before re-submitting." + } + } + }, + "statusMapping": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "required": [ + "SCHEMA_VERSION_UNSUPPORTED", + "VALIDATION_FAILED", + "ADOPTION_REPLAY_MISMATCH", + "REVISION_CONFLICT", + "NOT_FOUND", + "GONE_TOMBSTONED", + "CAPABILITY_UNSUPPORTED", + "ILLEGAL_TRANSITION", + "AUTHORITY_REQUIRED", + "APPROVAL_REQUIRED", + "CANCEL_PENDING", + "OVERLAP_FORBIDDEN", + "RETRY_DISPOSITION_INVALID", + "AMBIGUOUS_RETRY_FORBIDDEN", + "CURSOR_EXPIRED", + "STREAM_OUT_OF_ORDER", + "PAYLOAD_TOO_LARGE", + "DEADLINE_EXCEEDED", + "CONCURRENCY_LIMIT", + "INTERNAL" + ], + "properties": { + "SCHEMA_VERSION_UNSUPPORTED": { "const": 400 }, + "VALIDATION_FAILED": { "const": 400 }, + "ADOPTION_REPLAY_MISMATCH": { "const": 409 }, + "REVISION_CONFLICT": { "const": 409 }, + "NOT_FOUND": { "const": 404 }, + "GONE_TOMBSTONED": { "const": 410 }, + "CAPABILITY_UNSUPPORTED": { "const": 422 }, + "ILLEGAL_TRANSITION": { "const": 422 }, + "AUTHORITY_REQUIRED": { "const": 403 }, + "APPROVAL_REQUIRED": { "const": 403 }, + "CANCEL_PENDING": { "const": 409 }, + "OVERLAP_FORBIDDEN": { "const": 409 }, + "RETRY_DISPOSITION_INVALID": { "const": 422 }, + "AMBIGUOUS_RETRY_FORBIDDEN": { "const": 422 }, + "CURSOR_EXPIRED": { "const": 410 }, + "STREAM_OUT_OF_ORDER": { "const": 409 }, + "PAYLOAD_TOO_LARGE": { "const": 413 }, + "DEADLINE_EXCEEDED": { "const": 504 }, + "CONCURRENCY_LIMIT": { "const": 429 }, + "INTERNAL": { "const": 500 } + }, + "description": "Frozen mapping from typed code to HTTP status. Control-action responses surface the same code in the rejected envelope; the /actions transport maps 400/403/404/409/410/413/422/429/500/504 onto its existing status passthrough (api.rs json_response(status, ...))." + } + } +} diff --git a/spec/coven-automations/v1/event-envelope.schema.json b/spec/coven-automations/v1/event-envelope.schema.json new file mode 100644 index 00000000..c14f94b0 --- /dev/null +++ b/spec/coven-automations/v1/event-envelope.schema.json @@ -0,0 +1,211 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencoven.ai/spec/coven-automations/v1/event-envelope.schema.json", + "title": "Coven Automations v1 event/changefeed envelope", + "description": "Durable event envelope for the automations changefeed. Events are at-least-once, per-stream gaplessly sequenced, and the sole input to read-model rehydration. Consumers deduplicate on eventId, reject sequence regressions against their cursor, and resume from checkpoints that can expire (typed CURSOR_EXPIRED, never a silent rewind).", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "eventId", + "stream", + "sequence", + "recordedAt", + "observedAt", + "producer", + "kind", + "summary", + "payload", + "privacy" + ], + "properties": { + "schemaVersion": { "$ref": "common.schema.json#/$defs/schemaVersion" }, + "eventId": { + "type": "string", + "minLength": 20, + "maxLength": 64, + "pattern": "^[A-Za-z0-9]+$", + "description": "Globally unique, case-sensitive; duplicates of a delivered eventId are redeliveries and must be ignored, not re-applied." + }, + "stream": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id"], + "properties": { + "kind": { "enum": ["automation", "occurrence", "run", "feed"] }, + "id": { + "type": "string", + "minLength": 0, + "maxLength": 320, + "description": "automationId / occurrenceId / runId; the global feed uses kind=feed and a composite id (feed.)." + } + } + }, + "sequence": { + "$ref": "common.schema.json#/$defs/sequenceNumber", + "description": "Monotonically increasing, gapless within `stream`. Stream-local, starting at 0. Append is a compare-and-set on the stream head: out-of-order appends are refused (STREAM_OUT_OF_ORDER) rather than reordered." + }, + "recordedAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "When the authoritative store recorded the event. Read-model ordering uses (sequence, recordedAt), never observedAt." + }, + "observedAt": { + "$ref": "common.schema.json#/$defs/timestamp", + "description": "When the producing component observed the cause (may precede recordedAt)." + }, + "producer": { "$ref": "common.schema.json#/$defs/producerIdentity" }, + "causation": { + "type": "object", + "additionalProperties": false, + "properties": { + "adoptionKey": { "$ref": "common.schema.json#/$defs/adoptionKey" }, + "causeEventId": { + "type": "string", + "maxLength": 64, + "description": "eventId that caused this event, for chains." + }, + "correlationId": { "$ref": "common.schema.json#/$defs/correlationId" } + }, + "description": "Idempotency/causation/correlation context. adoptionKey ties the event to the command that committed it; replay consumers use it to prove command adoption happened exactly once." + }, + "automationId": { "$ref": "common.schema.json#/$defs/automationId" }, + "occurrenceId": { "$ref": "common.schema.json#/$defs/occurrenceId" }, + "runId": { "$ref": "common.schema.json#/$defs/runId" }, + "attemptId": { "$ref": "common.schema.json#/$defs/attemptId" }, + "kind": { "$ref": "#/$defs/eventKind" }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 300, + "description": "User-safe summary for UI surfaces; must not contain secrets, prompts, or raw payloads." + }, + "payload": { + "oneOf": [ + { "$ref": "#/$defs/definitionLifecyclePayload" }, + { "$ref": "#/$defs/transitionPayload" }, + { "$ref": "#/$defs/misfirePayload" }, + { "$ref": "#/$defs/receiptPayload" }, + { "$ref": "#/$defs/snapshotPayload" } + ] + }, + "privacy": { + "type": "object", + "additionalProperties": false, + "required": ["classification", "retention"], + "properties": { + "classification": { "$ref": "common.schema.json#/$defs/privacyClassification" }, + "retention": { "$ref": "common.schema.json#/$defs/retentionClass" } + } + }, + "integrity": { + "$ref": "common.schema.json#/$defs/digest", + "description": "Required where the deployment requires tamper evidence (for example receipt.recorded events): digest over the canonical envelope with this member removed." + } + }, + "$defs": { + "eventKind": { + "enum": [ + "definition.created", + "definition.revised", + "definition.activated", + "definition.paused", + "definition.disabled", + "definition.invalidated", + "definition.tombstoned", + "definition.imported", + "occurrence.transitioned", + "occurrence.misfire_recorded", + "run.transitioned", + "attempt.transitioned", + "receipt.recorded", + "feed.snapshot" + ] + }, + "definitionLifecyclePayload": { + "type": "object", + "additionalProperties": false, + "required": ["revision"], + "properties": { + "revision": { "type": "integer", "minimum": 1 }, + "definitionDigest": { "$ref": "common.schema.json#/$defs/digest" }, + "lifecycleState": { + "enum": ["draft", "paused", "active", "disabled", "invalid", "tombstoned"] + }, + "importedFrom": { "type": "string", "maxLength": 200 } + } + }, + "transitionPayload": { + "type": "object", + "additionalProperties": false, + "required": ["entity", "from", "to", "reason"], + "properties": { + "entity": { "enum": ["occurrence", "run", "attempt"] }, + "from": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "description": "Prior state; the literal `none` marks creation." + }, + "to": { "type": "string", "minLength": 1, "maxLength": 32 }, + "reason": { "type": "string", "minLength": 1, "maxLength": 500 }, + "fenceGeneration": { "$ref": "common.schema.json#/$defs/fenceToken" }, + "attemptNumber": { "type": "integer", "minimum": 1 }, + "commandAdoptionKey": { "$ref": "common.schema.json#/$defs/adoptionKey" } + } + }, + "misfirePayload": { + "type": "object", + "additionalProperties": false, + "required": ["disposition", "collapsedSlots"], + "properties": { + "disposition": { "$ref": "common.schema.json#/$defs/misfireDisposition" }, + "collapsedSlots": { + "type": "array", + "items": { "$ref": "common.schema.json#/$defs/timestamp" }, + "maxItems": 4096, + "description": "Missed slots this event accounts for (collapsed_to_latest). Recorded, never backfilled as occurrences." + } + } + }, + "receiptPayload": { + "type": "object", + "additionalProperties": false, + "required": ["receiptRef", "outcome"], + "properties": { + "receiptRef": { "$ref": "common.schema.json#/$defs/receiptId" }, + "outcome": { + "enum": ["succeeded", "failed", "cancelled", "timed_out", "ambiguous"] + }, + "sideEffectClass": { + "enum": [ + "none", + "local_read", + "local_write", + "external_read", + "external_mutation", + "irreversible_external_mutation" + ] + } + } + }, + "snapshotPayload": { + "type": "object", + "additionalProperties": false, + "required": ["throughSequence", "state"], + "properties": { + "throughSequence": { + "$ref": "common.schema.json#/$defs/sequenceNumber", + "description": "All events of the stream up to and including this sequence are compacted into `state`; a rehydrating consumer folds the snapshot then applies events strictly after it." + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "Compacted read-model state for the stream, itself schema-validated per object type." + }, + "reason": { + "enum": ["retention_compaction", "manual_snapshot"] + } + } + } + } +} diff --git a/spec/coven-automations/v1/protocol-version.json b/spec/coven-automations/v1/protocol-version.json new file mode 100644 index 00000000..15255f11 --- /dev/null +++ b/spec/coven-automations/v1/protocol-version.json @@ -0,0 +1,25 @@ +{ + "protocol": "Coven Automations", + "contractProfile": "coven.automations.v1", + "version": 1, + "status": "proposed", + "canonicalization": "jcs-rfc8785", + "digestAlgorithm": "sha256", + "profiles": [ + "coven.automations.v1" + ], + "refusedProfiles": [ + "coven.automations.v0" + ], + "unknownProfileBehavior": "fail-closed", + "contractVersionVsRelease": { + "contractProfile": "Identifies the wire contract only. Changes if and only if the published schema, state machine, or compatibility rules change.", + "implementationVersion": "Carried by the producing implementation (for example the Coven daemon release) and is independent of the contract profile. Clients must never infer contract semantics from an implementation version.", + "producerIdentity": "Event and receipt envelopes carry the producing implementation's identity and version alongside the contract profile." + }, + "wireNotes": [ + "All objects in this profile are JSON per draft 2020-12 schemas in this directory.", + "Digests and any future signatures are computed over RFC 8785 (JCS) canonical JSON, never over ad-hoc serialization.", + "Timestamps are RFC 3339 UTC with millisecond precision (for example 2026-08-30T09:00:00.000Z), matching the #816 store encoding." + ] +} diff --git a/spec/coven-automations/v1/state-machines.json b/spec/coven-automations/v1/state-machines.json new file mode 100644 index 00000000..d3b6e11a --- /dev/null +++ b/spec/coven-automations/v1/state-machines.json @@ -0,0 +1,186 @@ +{ + "contractProfile": "coven.automations.v1", + "version": 1, + "notes": [ + "Authoritative state machines for coven.automations.v1. Clients do not author state: transitions are committed by command handlers or the scheduler, never by arbitrary client writes.", + "`on` names the cause class; `actor` names who may commit the transition; `guard` names the condition that must hold. Property tests consume this file to prove invariant preservation." + ], + "machines": [ + { + "id": "occurrence.v1", + "entity": "occurrence", + "initial": "planned", + "terminalStates": ["succeeded", "failed", "cancelled", "timed_out", "skipped", "superseded"], + "states": [ + { "name": "planned", "terminal": false }, + { "name": "eligible", "terminal": false }, + { "name": "claimed", "terminal": false }, + { "name": "dispatching", "terminal": false }, + { "name": "running", "terminal": false }, + { "name": "recovering", "terminal": false }, + { "name": "recovery_required", "terminal": false }, + { "name": "succeeded", "terminal": true }, + { "name": "failed", "terminal": true }, + { "name": "cancelled", "terminal": true }, + { "name": "timed_out", "terminal": true }, + { "name": "skipped", "terminal": true }, + { "name": "superseded", "terminal": true } + ], + "transitions": [ + { "from": "planned", "to": "eligible", "on": "eligibility_passed", "actor": "scheduler" }, + { "from": "planned", "to": "skipped", "on": "misfire", "actor": "scheduler", "guard": "misfire policy disposes this slot (e.g. overlap_forbid, paused, invalid)" }, + { "from": "planned", "to": "superseded", "on": "definition_superseded", "actor": "scheduler", "guard": "a newer revision replaced the definition before this slot was claimed" }, + { "from": "planned", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler", "guard": "cancellation command committed before any claim" }, + { "from": "eligible", "to": "claimed", "on": "claim", "actor": "scheduler", "guard": "compare-and-set on state='eligible' or 'planned' with lease set; increments fence generation" }, + { "from": "eligible", "to": "skipped", "on": "misfire", "actor": "scheduler" }, + { "from": "eligible", "to": "superseded", "on": "definition_superseded", "actor": "scheduler" }, + { "from": "eligible", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler" }, + { "from": "claimed", "to": "dispatching", "on": "attempt_adopted", "actor": "dispatcher", "guard": "attempt created with adoptionKey; fence generation matches the claim" }, + { "from": "claimed", "to": "recovering", "on": "lease_expired", "actor": "scheduler", "guard": "leaseExpiresAt < now and no runtime evidence recorded" }, + { "from": "claimed", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler", "guard": "cancellation reconciled before dispatch" }, + { "from": "dispatching", "to": "running", "on": "runtime_started", "actor": "dispatcher", "guard": "runtime acknowledged the session start (attempt started)" }, + { "from": "dispatching", "to": "failed", "on": "launch_refused", "actor": "dispatcher", "guard": "launch failed deterministically before any side effect; occurrence settles failed with reason launch_refused" }, + { "from": "dispatching", "to": "recovering", "on": "lease_expired", "actor": "scheduler", "guard": "no runtime evidence before lease expiry" }, + { "from": "dispatching", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler", "guard": "no attempt reached started; otherwise cancellation waits for reconciliation" }, + { "from": "running", "to": "succeeded", "on": "run_settled", "actor": "dispatcher", "guard": "runtime evidence proves completion; receipt recorded" }, + { "from": "running", "to": "failed", "on": "run_settled", "actor": "dispatcher", "guard": "runtime evidence proves failure" }, + { "from": "running", "to": "timed_out", "on": "timeout", "actor": "scheduler", "guard": "perRunMinutes elapsed without settled evidence" }, + { "from": "running", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler", "guard": "cancellation acknowledged by runtime or reconciled by recovery" }, + { "from": "running", "to": "recovery_required", "on": "evidence_lost", "actor": "scheduler", "guard": "runtime lost mid-run: outcome cannot be determined automatically" }, + { "from": "recovering", "to": "failed", "on": "recovery_determined", "actor": "scheduler", "guard": "no side effects were possible (sideEffectClass bounds proven)" }, + { "from": "recovering", "to": "recovery_required", "on": "recovery_ambiguous", "actor": "scheduler", "guard": "side effects cannot be ruled out; requires explicit operator recovery" }, + { "from": "recovery_required", "to": "failed", "on": "operator_recovery_failed", "actor": "command_handler", "guard": "occurrence.recover.v1 with evidenceDetermination=failed_deterministic" }, + { "from": "recovery_required", "to": "dispatching", "on": "operator_recovery_attempt", "actor": "command_handler", "guard": "occurrence.recover.v1 with evidenceDetermination=retry_with_new_attempt; opens a new attempt carrying priorDisposition=ambiguous" } + ] + }, + { + "id": "attempt.v1", + "entity": "attempt", + "initial": "adopted", + "terminalStates": ["succeeded", "failed", "cancelled", "timed_out", "ambiguous"], + "states": [ + { "name": "adopted", "terminal": false }, + { "name": "dispatching", "terminal": false }, + { "name": "started", "terminal": false }, + { "name": "observing", "terminal": false }, + { "name": "succeeded", "terminal": true }, + { "name": "failed", "terminal": true }, + { "name": "cancelled", "terminal": true }, + { "name": "timed_out", "terminal": true }, + { "name": "ambiguous", "terminal": true } + ], + "transitions": [ + { "from": "adopted", "to": "dispatching", "on": "dispatch", "actor": "dispatcher", "guard": "dispatch generation incremented; exactly one dispatch in flight per attempt" }, + { "from": "adopted", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler", "guard": "cancellation committed before dispatch" }, + { "from": "dispatching", "to": "started", "on": "runtime_started", "actor": "dispatcher", "guard": "runtime session bound; a second session bind is ILLEGAL_TRANSITION" }, + { "from": "dispatching", "to": "failed", "on": "launch_refused", "actor": "dispatcher", "guard": "deterministic pre-side-effect refusal" }, + { "from": "dispatching", "to": "ambiguous", "on": "dispatch_unconfirmed", "actor": "dispatcher", "guard": "dispatch sent but no deterministic ack before lease expiry; terminal for the attempt" }, + { "from": "dispatching", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler", "guard": "cancellation acknowledged before runtime start" }, + { "from": "started", "to": "observing", "on": "evidence_stream_open", "actor": "worker", "guard": "output/event cursors established" }, + { "from": "started", "to": "succeeded", "on": "run_settled", "actor": "worker", "guard": "terminal evidence without an observing phase" }, + { "from": "started", "to": "ambiguous", "on": "evidence_lost", "actor": "scheduler", "guard": "session vanished before evidence" }, + { "from": "observing", "to": "succeeded", "on": "run_settled", "actor": "worker", "guard": "verified completion evidence" }, + { "from": "observing", "to": "failed", "on": "run_settled", "actor": "worker", "guard": "verified failure evidence" }, + { "from": "observing", "to": "timed_out", "on": "timeout", "actor": "scheduler", "guard": "perRunMinutes elapsed" }, + { "from": "observing", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler", "guard": "cancellation acknowledged by runtime" }, + { "from": "observing", "to": "ambiguous", "on": "evidence_lost", "actor": "scheduler", "guard": "heartbeat/lease evidence expired mid-observation" } + ] + }, + { + "id": "run.v1", + "entity": "run", + "initial": "accepted", + "terminalStates": ["succeeded", "failed", "cancelled", "timed_out", "ambiguous"], + "states": [ + { "name": "accepted", "terminal": false }, + { "name": "running", "terminal": false }, + { "name": "succeeded", "terminal": true }, + { "name": "failed", "terminal": true }, + { "name": "cancelled", "terminal": true }, + { "name": "timed_out", "terminal": true }, + { "name": "ambiguous", "terminal": true } + ], + "transitions": [ + { "from": "accepted", "to": "running", "on": "attempt_started", "actor": "dispatcher", "guard": "command adoption committed before any side effect; currentAttemptId set" }, + { "from": "accepted", "to": "failed", "on": "launch_refused", "actor": "dispatcher", "guard": "every attempt failed deterministically before evidence" }, + { "from": "accepted", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler" }, + { "from": "running", "to": "succeeded", "on": "attempt_settled", "actor": "dispatcher", "guard": "an attempt settled succeeded; receipt recorded" }, + { "from": "running", "to": "failed", "on": "attempt_settled", "actor": "dispatcher", "guard": "an attempt settled failed and retry policy opens no further attempt" }, + { "from": "running", "to": "timed_out", "on": "timeout", "actor": "scheduler" }, + { "from": "running", "to": "cancelled", "on": "cancel_acknowledged", "actor": "command_handler" }, + { "from": "running", "to": "ambiguous", "on": "attempt_ambiguous", "actor": "dispatcher", "guard": "the current attempt settled ambiguous and no operator recovery has resolved it" } + ] + }, + { + "id": "definition.v1", + "entity": "definition", + "initial": "draft", + "terminalStates": ["tombstoned"], + "states": [ + { "name": "draft", "terminal": false }, + { "name": "paused", "terminal": false }, + { "name": "active", "terminal": false }, + { "name": "disabled", "terminal": false }, + { "name": "invalid", "terminal": false }, + { "name": "tombstoned", "terminal": true } + ], + "transitions": [ + { "from": "draft", "to": "paused", "on": "definition_revise_committed", "actor": "command_handler", "guard": "definition validated; revision incremented" }, + { "from": "draft", "to": "tombstoned", "on": "definition_tombstoned", "actor": "command_handler" }, + { "from": "paused", "to": "active", "on": "definition_activated", "actor": "command_handler", "guard": "familiar, authority, and runtime requirements validated" }, + { "from": "paused", "to": "disabled", "on": "definition_disabled", "actor": "command_handler" }, + { "from": "paused", "to": "tombstoned", "on": "definition_tombstoned", "actor": "command_handler" }, + { "from": "active", "to": "paused", "on": "definition_paused", "actor": "command_handler" }, + { "from": "active", "to": "disabled", "on": "definition_disabled", "actor": "command_handler" }, + { "from": "active", "to": "invalid", "on": "definition_invalidated", "actor": "command_handler", "guard": "a revise committed an invalid body or dependencies (familiar/runtime/authority) became unsatisfiable" }, + { "from": "active", "to": "tombstoned", "on": "definition_tombstoned", "actor": "command_handler" }, + { "from": "disabled", "to": "paused", "on": "definition_reenabled", "actor": "command_handler", "guard": "explicit re-enable; disabled never auto-reactivates" }, + { "from": "disabled", "to": "tombstoned", "on": "definition_tombstoned", "actor": "command_handler" }, + { "from": "invalid", "to": "paused", "on": "definition_revise_committed", "actor": "command_handler", "guard": "a revise repaired the body and validation passed" }, + { "from": "invalid", "to": "tombstoned", "on": "definition_tombstoned", "actor": "command_handler" } + ] + } + ], + "invariants": [ + { + "id": "adoption-before-side-effects", + "statement": "Command adoption commits before any consequential side effect is issued; a run is accepted (run.v1 accepted) before dispatch begins." + }, + { + "id": "one-fence-one-run", + "statement": "One occurrence fence generation cannot own two accepted runs: acceptance requires a compare-and-set on the occurrence fence generation, and a second acceptance with the same generation is a typed conflict." + }, + { + "id": "one-attempt-one-session", + "statement": "One attempt cannot bind two runtime sessions; a second session bind on the same attempt is ILLEGAL_TRANSITION and commits nothing." + }, + { + "id": "terminal-no-regress", + "statement": "Terminal states never regress; no transition in any machine above leaves a terminal state." + }, + { + "id": "no-evidence-no-success", + "statement": "Absence of runtime evidence can never become success; the only transitions out of evidence loss are failed, recovery paths, ambiguous, or timed_out." + }, + { + "id": "cancellation-is-a-request", + "statement": "Cancellation is a request until acknowledged or reconciled; CANCEL_PENDING is returned while a running attempt has not acknowledged, and the occurrence settles cancelled only after acknowledgment or reconciliation." + }, + { + "id": "retry-new-attempt", + "statement": "Retry creates a new attempt with attemptNumber incremented and priorDisposition set; the prior attempt's record is never rewritten." + }, + { + "id": "ambiguous-never-auto-retried", + "statement": "Ambiguous mutating work is not automatically retried; only occurrence.recover.v1 with an explicit operator determination can open work after an ambiguous disposition." + }, + { + "id": "revision-pins-history", + "statement": "Definition revision changes never rewrite historical occurrences, runs, attempts, or receipts; each pins the automationRevision and definition digest it was created and executed against." + }, + { + "id": "tombstone-preserves-history", + "statement": "Deleting a definition tombstones it without erasing required history; tombstoned definitions never plan, claim, or run, and their records remain readable." + } + ] +} diff --git a/spec/coven-automations/v1/test-vectors.json b/spec/coven-automations/v1/test-vectors.json new file mode 100644 index 00000000..a05c7960 --- /dev/null +++ b/spec/coven-automations/v1/test-vectors.json @@ -0,0 +1,2085 @@ +{ + "contractProfile": "coven.automations.v1", + "version": 1, + "status": "proposed", + "digestRecipe": { + "canonicalization": "RFC 8785 (JCS): UTF-8, key-sorted, no whitespace, minimal escaping.", + "digest": "SHA-256, lowercase hex, over the canonical serialization of the object with every `integrity` member removed.", + "fixtureScope": "All fixtures use integers and ASCII strings only, so any conformant JCS implementation reproduces the pinned byte strings exactly.", + "verification": "A runner recomputes the pinned digests before trusting the vectors; a mismatch means the vector bytes were altered and the file must not be used." + }, + "caseKinds": { + "schema": "Validate `object` against `targetSchema` (draft 2020-12, with sibling schemas from this directory loaded). accept/reject is the whole verdict.", + "stateMachine": "Consult state-machines.json. `attemptedTransition` must be refused because no transition leaves the terminal state `fromState`.", + "adoption": "Consult command-envelope semantics. The case pins what a conformant command handler commits or refuses, independent of transport.", + "changefeed": "Consult event-envelope semantics. The case pins duplicate handling, ordering, and deterministic read-model rehydration." + }, + "fixtures": { + "definition.golden": { + "schemaVersion": "coven.automations.v1", + "automationId": "daily-notes", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + }, + "occurrence.golden": { + "schemaVersion": "coven.automations.v1", + "occurrenceId": "daily-notes-1756544400000", + "automationId": "daily-notes", + "automationRevision": 1, + "triggerIdentity": { + "kind": "schedule.slot", + "rruleRef": "FREQ=DAILY;BYHOUR=9" + }, + "occurrenceKey": "daily-notes@2026-08-30T09:00:00.000Z", + "scheduledFor": "2026-08-30T09:00:00.000Z", + "observedAt": "2026-08-30T09:00:00.000Z", + "eligibleAt": "2026-08-30T09:00:01.000Z", + "state": "claimed", + "stateReason": "claimed_by_daemon", + "fence": { + "generation": 1, + "claimedBy": "daemon@host-a", + "leaseExpiresAt": "2026-08-30T09:30:00.000Z" + }, + "misfireDisposition": "none", + "claimMetadata": { + "claimedAt": "2026-08-30T09:00:01.000Z", + "leaseMinutes": 60 + }, + "activeRunRef": "run-daily-notes-0001", + "createdAt": "2026-08-30T09:00:00.000Z", + "updatedAt": "2026-08-30T09:00:02.000Z", + "eventWindow": { + "firstSequence": 0, + "lastSequence": 3 + }, + "extensions": {} + }, + "run.golden": { + "schemaVersion": "coven.automations.v1", + "runId": "run-daily-notes-0001", + "occurrenceId": "daily-notes-1756544400000", + "automationId": "daily-notes", + "automationRevision": 1, + "binding": { + "familiar": { + "familiarId": "charm" + }, + "authority": { + "principal": { + "principalId": "principal:tim" + }, + "approval": { + "approvalPolicyRef": "policy://authority/familiars/charm", + "approvalRecordRef": "approval-2026-08-30-0001" + }, + "authenticationClass": "devicekey" + }, + "runtime": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + } + }, + "state": "succeeded", + "stateReason": "attempt_settled_succeeded", + "attemptCount": 1, + "terminalDisposition": { + "outcome": "succeeded", + "detail": "verified completion evidence" + }, + "delivery": { + "status": "committed", + "target": "~/projects/notes/today.md" + }, + "receiptRef": "receipt-daily-notes-0001", + "startedAt": "2026-08-30T09:00:02.000Z", + "finishedAt": "2026-08-30T09:00:05.000Z", + "extensions": {} + }, + "attempt.golden": { + "schemaVersion": "coven.automations.v1", + "attemptId": "att-daily-notes-0001-1", + "runId": "run-daily-notes-0001", + "occurrenceId": "daily-notes-1756544400000", + "attemptNumber": 1, + "adoptionKey": "adopt:run-daily-notes-0001:1", + "dispatchFence": { + "occurrenceFenceGeneration": 1, + "dispatchGeneration": 1 + }, + "workerCorrelation": { + "workerId": "dispatcher@host-a", + "sessionId": "session-coven-code-7", + "adoptedAt": "2026-08-30T09:00:01.000Z" + }, + "retryClassification": { + "classification": "initial", + "eligibleClasses": [ + "transient_dispatch" + ] + }, + "leaseObservations": [ + { + "observedAt": "2026-08-30T09:00:03.000Z", + "heartbeatOk": true + } + ], + "outputCursors": { + "eventCursor": 3, + "logCursor": 128 + }, + "state": "succeeded", + "stateReason": "verified completion evidence", + "openedAt": "2026-08-30T09:00:01.000Z", + "settledAt": "2026-08-30T09:00:05.000Z", + "extensions": {} + }, + "receipt.golden": { + "schemaVersion": "coven.automations.v1", + "receiptId": "receipt-daily-notes-0001", + "automationId": "daily-notes", + "automationRevision": 1, + "definitionDigest": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + }, + "occurrenceId": "daily-notes-1756544400000", + "occurrenceFenceGeneration": 1, + "runId": "run-daily-notes-0001", + "attemptId": "att-daily-notes-0001-1", + "attemptNumber": 1, + "identity": { + "familiarId": "charm" + }, + "authority": { + "principal": { + "principalId": "principal:tim" + }, + "approval": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtime": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "exercisedCapabilities": [ + "sessions.launch" + ], + "sideEffectClass": "local_write", + "outcome": { + "disposition": "succeeded", + "recoveryDisposition": "not_required" + }, + "producedAt": "2026-08-30T09:00:05.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a", + "implementationVersion": "0.9.0" + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + }, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "3b278869178dc3a8461a0cae5a1c671e77085e468261d651460ccf6822300418", + "authentication": "none" + } + }, + "command.create.golden": { + "schemaVersion": "coven.automations.v1", + "command": "definition.create.v1", + "adoptionKey": "adopt:create-daily-notes-0001", + "origin": { + "principal": { + "principalId": "principal:tim" + }, + "channel": "sdk", + "authenticationClass": "devicekey", + "requestedAt": "2026-08-30T09:00:00.000Z", + "correlationId": "corr-create-0001" + }, + "intent": { + "statement": "Create the daily notes routine." + }, + "payload": { + "definition": { + "schemaVersion": "coven.automations.v1", + "automationId": "daily-notes", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + } + }, + "event.occurrence.sequence": [ + { + "schemaVersion": "coven.automations.v1", + "eventId": "evtocc00a7c3e9d24b6f8051", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "sequence": 0, + "recordedAt": "2026-08-30T09:00:00.000Z", + "observedAt": "2026-08-30T09:00:00.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a" + }, + "automationId": "daily-notes", + "occurrenceId": "daily-notes-1756544400000", + "kind": "occurrence.transitioned", + "summary": "occurrence occurrence none -> planned", + "payload": { + "entity": "occurrence", + "from": "none", + "to": "planned", + "reason": "slot_fenced", + "fenceGeneration": 1 + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + } + }, + { + "schemaVersion": "coven.automations.v1", + "eventId": "evtocc01a7c3e9d24b6f8051", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "sequence": 1, + "recordedAt": "2026-08-30T09:00:01.000Z", + "observedAt": "2026-08-30T09:00:01.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a" + }, + "automationId": "daily-notes", + "occurrenceId": "daily-notes-1756544400000", + "kind": "occurrence.transitioned", + "summary": "occurrence occurrence planned -> eligible", + "payload": { + "entity": "occurrence", + "from": "planned", + "to": "eligible", + "reason": "eligibility_passed", + "fenceGeneration": 1 + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + } + }, + { + "schemaVersion": "coven.automations.v1", + "eventId": "evtocc02a7c3e9d24b6f8051", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "sequence": 2, + "recordedAt": "2026-08-30T09:00:02.000Z", + "observedAt": "2026-08-30T09:00:02.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a" + }, + "automationId": "daily-notes", + "occurrenceId": "daily-notes-1756544400000", + "kind": "occurrence.transitioned", + "summary": "occurrence occurrence eligible -> claimed", + "payload": { + "entity": "occurrence", + "from": "eligible", + "to": "claimed", + "reason": "claim", + "fenceGeneration": 1 + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + } + }, + { + "schemaVersion": "coven.automations.v1", + "eventId": "evtocc03a7c3e9d24b6f8051", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "sequence": 3, + "recordedAt": "2026-08-30T09:00:03.000Z", + "observedAt": "2026-08-30T09:00:03.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a" + }, + "automationId": "daily-notes", + "occurrenceId": "daily-notes-1756544400000", + "kind": "occurrence.transitioned", + "summary": "occurrence occurrence claimed -> dispatching", + "payload": { + "entity": "occurrence", + "from": "claimed", + "to": "dispatching", + "reason": "attempt_adopted", + "fenceGeneration": 1 + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + } + }, + { + "schemaVersion": "coven.automations.v1", + "eventId": "evtocc04a7c3e9d24b6f8051", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "sequence": 4, + "recordedAt": "2026-08-30T09:00:04.000Z", + "observedAt": "2026-08-30T09:00:04.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a" + }, + "automationId": "daily-notes", + "occurrenceId": "daily-notes-1756544400000", + "kind": "occurrence.transitioned", + "summary": "occurrence occurrence dispatching -> running", + "payload": { + "entity": "occurrence", + "from": "dispatching", + "to": "running", + "reason": "runtime_started", + "fenceGeneration": 1 + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + } + }, + { + "schemaVersion": "coven.automations.v1", + "eventId": "evtocc05a7c3e9d24b6f8051", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "sequence": 5, + "recordedAt": "2026-08-30T09:00:05.000Z", + "observedAt": "2026-08-30T09:00:05.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a" + }, + "automationId": "daily-notes", + "occurrenceId": "daily-notes-1756544400000", + "kind": "occurrence.transitioned", + "summary": "occurrence occurrence running -> succeeded", + "payload": { + "entity": "occurrence", + "from": "running", + "to": "succeeded", + "reason": "run_settled", + "fenceGeneration": 1 + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + } + } + ] + }, + "cases": [ + { + "name": "definition-golden-valid", + "kind": "schema", + "targetSchema": "automation-definition.schema.json", + "expected": "accept", + "object": { + "schemaVersion": "coven.automations.v1", + "automationId": "daily-notes", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + }, + { + "name": "definition-rejects-unknown-field", + "kind": "schema", + "targetSchema": "automation-definition.schema.json", + "expected": "reject", + "reason": "additionalProperties=false; unknown fields fail closed (extensions bag is the only optional channel).", + "object": { + "schemaVersion": "coven.automations.v1", + "automationId": "no-unknown-fields", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + }, + "autoRetry": true + } + }, + { + "name": "definition-rejects-unknown-trigger-variant", + "kind": "schema", + "targetSchema": "automation-definition.schema.json", + "expected": "reject", + "reason": "v1 unions are closed; trigger.webhook is not a v1 variant. Producers that pass validation through must still refuse with CAPABILITY_UNSUPPORTED.", + "object": { + "schemaVersion": "coven.automations.v1", + "automationId": "no-unknown-variants", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "webhook", + "version": 1, + "webhook": { + "url": "https://example.invalid/hook" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + }, + { + "name": "definition-rejects-downgrade", + "kind": "schema", + "targetSchema": "automation-definition.schema.json", + "expected": "reject", + "reason": "schemaVersion const coven.automations.v1; v0 is a refused profile.", + "object": { + "schemaVersion": "coven.automations.v0", + "automationId": "downgrade-refused", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + }, + { + "name": "definition-rejects-upgrade", + "kind": "schema", + "targetSchema": "automation-definition.schema.json", + "expected": "reject", + "reason": "Unknown future profile fails closed; consumers never approximate v2 objects with v1 semantics.", + "object": { + "schemaVersion": "coven.automations.v2", + "automationId": "upgrade-refused", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + }, + { + "name": "definition-rejects-missing-prompt", + "kind": "schema", + "targetSchema": "automation-definition.schema.json", + "expected": "reject", + "reason": "familiarInvocation requires a non-empty prompt.", + "object": { + "schemaVersion": "coven.automations.v1", + "automationId": "missing-prompt-refused", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "cwd": "~/x" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + }, + { + "name": "occurrence-golden-valid", + "kind": "schema", + "targetSchema": "automation-occurrence.schema.json", + "expected": "accept", + "object": { + "schemaVersion": "coven.automations.v1", + "occurrenceId": "daily-notes-1756544400000", + "automationId": "daily-notes", + "automationRevision": 1, + "triggerIdentity": { + "kind": "schedule.slot", + "rruleRef": "FREQ=DAILY;BYHOUR=9" + }, + "occurrenceKey": "daily-notes@2026-08-30T09:00:00.000Z", + "scheduledFor": "2026-08-30T09:00:00.000Z", + "observedAt": "2026-08-30T09:00:00.000Z", + "eligibleAt": "2026-08-30T09:00:01.000Z", + "state": "claimed", + "stateReason": "claimed_by_daemon", + "fence": { + "generation": 1, + "claimedBy": "daemon@host-a", + "leaseExpiresAt": "2026-08-30T09:30:00.000Z" + }, + "misfireDisposition": "none", + "claimMetadata": { + "claimedAt": "2026-08-30T09:00:01.000Z", + "leaseMinutes": 60 + }, + "activeRunRef": "run-daily-notes-0001", + "createdAt": "2026-08-30T09:00:00.000Z", + "updatedAt": "2026-08-30T09:00:02.000Z", + "eventWindow": { + "firstSequence": 0, + "lastSequence": 3 + }, + "extensions": {} + } + }, + { + "name": "run-golden-valid", + "kind": "schema", + "targetSchema": "automation-run.schema.json", + "expected": "accept", + "object": { + "schemaVersion": "coven.automations.v1", + "runId": "run-daily-notes-0001", + "occurrenceId": "daily-notes-1756544400000", + "automationId": "daily-notes", + "automationRevision": 1, + "binding": { + "familiar": { + "familiarId": "charm" + }, + "authority": { + "principal": { + "principalId": "principal:tim" + }, + "approval": { + "approvalPolicyRef": "policy://authority/familiars/charm", + "approvalRecordRef": "approval-2026-08-30-0001" + }, + "authenticationClass": "devicekey" + }, + "runtime": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + } + }, + "state": "succeeded", + "stateReason": "attempt_settled_succeeded", + "attemptCount": 1, + "terminalDisposition": { + "outcome": "succeeded", + "detail": "verified completion evidence" + }, + "delivery": { + "status": "committed", + "target": "~/projects/notes/today.md" + }, + "receiptRef": "receipt-daily-notes-0001", + "startedAt": "2026-08-30T09:00:02.000Z", + "finishedAt": "2026-08-30T09:00:05.000Z", + "extensions": {} + } + }, + { + "name": "attempt-golden-valid", + "kind": "schema", + "targetSchema": "automation-attempt.schema.json", + "expected": "accept", + "object": { + "schemaVersion": "coven.automations.v1", + "attemptId": "att-daily-notes-0001-1", + "runId": "run-daily-notes-0001", + "occurrenceId": "daily-notes-1756544400000", + "attemptNumber": 1, + "adoptionKey": "adopt:run-daily-notes-0001:1", + "dispatchFence": { + "occurrenceFenceGeneration": 1, + "dispatchGeneration": 1 + }, + "workerCorrelation": { + "workerId": "dispatcher@host-a", + "sessionId": "session-coven-code-7", + "adoptedAt": "2026-08-30T09:00:01.000Z" + }, + "retryClassification": { + "classification": "initial", + "eligibleClasses": [ + "transient_dispatch" + ] + }, + "leaseObservations": [ + { + "observedAt": "2026-08-30T09:00:03.000Z", + "heartbeatOk": true + } + ], + "outputCursors": { + "eventCursor": 3, + "logCursor": 128 + }, + "state": "succeeded", + "stateReason": "verified completion evidence", + "openedAt": "2026-08-30T09:00:01.000Z", + "settledAt": "2026-08-30T09:00:05.000Z", + "extensions": {} + } + }, + { + "name": "receipt-golden-valid", + "kind": "schema", + "targetSchema": "automation-receipt.schema.json", + "expected": "accept", + "object": { + "schemaVersion": "coven.automations.v1", + "receiptId": "receipt-daily-notes-0001", + "automationId": "daily-notes", + "automationRevision": 1, + "definitionDigest": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + }, + "occurrenceId": "daily-notes-1756544400000", + "occurrenceFenceGeneration": 1, + "runId": "run-daily-notes-0001", + "attemptId": "att-daily-notes-0001-1", + "attemptNumber": 1, + "identity": { + "familiarId": "charm" + }, + "authority": { + "principal": { + "principalId": "principal:tim" + }, + "approval": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtime": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "exercisedCapabilities": [ + "sessions.launch" + ], + "sideEffectClass": "local_write", + "outcome": { + "disposition": "succeeded", + "recoveryDisposition": "not_required" + }, + "producedAt": "2026-08-30T09:00:05.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a", + "implementationVersion": "0.9.0" + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + }, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "3b278869178dc3a8461a0cae5a1c671e77085e468261d651460ccf6822300418", + "authentication": "none" + } + } + }, + { + "name": "command-create-golden-valid", + "kind": "schema", + "targetSchema": "command-envelope.schema.json", + "expected": "accept", + "object": { + "schemaVersion": "coven.automations.v1", + "command": "definition.create.v1", + "adoptionKey": "adopt:create-daily-notes-0001", + "origin": { + "principal": { + "principalId": "principal:tim" + }, + "channel": "sdk", + "authenticationClass": "devicekey", + "requestedAt": "2026-08-30T09:00:00.000Z", + "correlationId": "corr-create-0001" + }, + "intent": { + "statement": "Create the daily notes routine." + }, + "payload": { + "definition": { + "schemaVersion": "coven.automations.v1", + "automationId": "daily-notes", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + } + } + }, + { + "name": "command-revise-requires-expected-revision", + "kind": "schema", + "targetSchema": "command-envelope.schema.json", + "expected": "reject", + "reason": "definition.revise.v1 requires expectedRevision; without it nothing may commit.", + "object": { + "schemaVersion": "coven.automations.v1", + "command": "definition.revise.v1", + "adoptionKey": "adopt:revise-daily-notes-0002", + "origin": { + "principal": { + "principalId": "principal:tim" + }, + "channel": "sdk", + "requestedAt": "2026-08-30T09:00:01.000Z" + }, + "intent": { + "statement": "Move the daily notes slot to 10:00." + }, + "payload": { + "definition": { + "schemaVersion": "coven.automations.v1", + "automationId": "daily-notes", + "revision": 3, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=10", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + } + } + }, + { + "name": "definition-terminal-state-never-regresses", + "kind": "stateMachine", + "machine": "occurrence.v1", + "fromState": "succeeded", + "attemptedTransition": { + "to": "running", + "on": "runtime_started" + }, + "expected": "reject", + "reason": "No transition in state-machines.json leaves a terminal state." + }, + { + "name": "definition-revision-conflict-commits-nothing", + "kind": "adoption", + "given": { + "automationId": "daily-notes", + "currentRevision": 5, + "previouslyCommittedAdoptionKeys": [ + "adopt:create-daily-notes-0001" + ] + }, + "command": { + "schemaVersion": "coven.automations.v1", + "command": "definition.revise.v1", + "adoptionKey": "adopt:revise-daily-notes-0002", + "expectedRevision": 2, + "origin": { + "principal": { + "principalId": "principal:tim" + }, + "channel": "sdk", + "requestedAt": "2026-08-30T09:00:01.000Z" + }, + "intent": { + "statement": "Move the daily notes slot to 10:00." + }, + "payload": { + "definition": { + "schemaVersion": "coven.automations.v1", + "automationId": "daily-notes", + "revision": 3, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=10", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + } + }, + "expected": "reject", + "errorCode": "REVISION_CONFLICT", + "httpStatus": 409, + "detailsMustInclude": [ + "currentRevision" + ], + "reason": "expectedRevision=2 but the definition is currently at revision 5: the handler commits nothing, leaves history untouched, and returns currentRevision=5 in the error details. The payload's revision (3) also exposes a stale-belief write; both mismatch paths resolve to REVISION_CONFLICT." + }, + { + "name": "definition-adoption-replay-returns-first-outcome", + "kind": "adoption", + "given": { + "automationId": "daily-notes", + "previouslyCommittedAdoptionKeys": { + "adopt:create-daily-notes-0001": { + "command": "definition.create.v1", + "committedAt": "2026-08-30T09:00:00.000Z", + "revision": 1 + } + } + }, + "command": { + "schemaVersion": "coven.automations.v1", + "command": "definition.create.v1", + "adoptionKey": "adopt:create-daily-notes-0001", + "origin": { + "principal": { + "principalId": "principal:tim" + }, + "channel": "sdk", + "authenticationClass": "devicekey", + "requestedAt": "2026-08-30T09:00:00.000Z", + "correlationId": "corr-create-0001" + }, + "intent": { + "statement": "Create the daily notes routine." + }, + "payload": { + "definition": { + "schemaVersion": "coven.automations.v1", + "automationId": "daily-notes", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + } + }, + "expected": "replayed", + "resultMustEqual": "the result recorded when adoptionKey adopt:create-daily-notes-0001 first committed, including revision 1 and the original eventRef", + "reason": "A repeated command with the same adoptionKey is a redelivery: the first committed outcome is returned unchanged and idempotent; no second definition, event, or revision is created." + }, + { + "name": "definition-adoption-replay-mismatch-is-a-conflict", + "kind": "adoption", + "given": { + "automationId": "daily-notes", + "previouslyCommittedAdoptionKeys": { + "adopt:create-daily-notes-0001": { + "command": "definition.create.v1", + "committedAt": "2026-08-30T09:00:00.000Z", + "revision": 1 + } + } + }, + "command": { + "schemaVersion": "coven.automations.v1", + "command": "definition.create.v1", + "adoptionKey": "adopt:create-daily-notes-0001", + "origin": { + "principal": { + "principalId": "principal:tim" + }, + "channel": "sdk", + "authenticationClass": "devicekey", + "requestedAt": "2026-08-30T09:00:00.000Z", + "correlationId": "corr-create-0001" + }, + "intent": { + "statement": "Create a different routine under a reused key." + }, + "payload": { + "definition": { + "schemaVersion": "coven.automations.v1", + "automationId": "weekly-notes", + "revision": 1, + "lifecycleState": "active", + "display": { + "name": "Daily notes", + "description": "Write the daily reflection into the notes target.", + "tags": [ + "notes", + "daily" + ] + }, + "trigger": { + "variant": "schedule", + "version": 1, + "schedule": { + "rrule": "FREQ=DAILY;BYHOUR=9", + "timezone": "utc" + } + }, + "conditions": [], + "action": { + "variant": "familiarInvocation", + "version": 1, + "prompt": "Write the daily reflection.", + "cwd": "~/projects/notes" + }, + "binding": { + "familiarBindingPolicy": "exact", + "familiarId": "charm", + "authority": { + "approvalPolicyRef": "policy://authority/familiars/charm" + } + }, + "runtimeRequirements": { + "runtimeId": "coven-code", + "capabilities": [ + "sessions.launch" + ] + }, + "policies": { + "timeout": { + "perRunMinutes": 30 + }, + "retry": { + "maxAttempts": 3, + "backoffPolicy": "exponential", + "retryableClasses": [ + "transient_dispatch" + ] + }, + "concurrency": { + "overlap": "forbid" + }, + "misfire": { + "disposition": "latest" + }, + "delivery": { + "outputTarget": "~/projects/notes/today.md", + "mode": "atomic" + }, + "retention": { + "occurrenceHistory": { + "classification": "standard" + }, + "runLogs": { + "classification": "standard" + }, + "receipts": { + "classification": "extended" + } + } + }, + "provenance": { + "createdBy": { + "principalId": "principal:tim" + }, + "createdAt": "2026-08-30T09:00:00.000Z" + }, + "activation": { + "effectiveFrom": "2026-08-30T09:00:00.000Z" + }, + "extensions": {}, + "integrity": { + "algorithm": "sha256", + "canonicalization": "jcs-rfc8785", + "value": "8921b840a98f0b700d0144e70b9418af2431f9863bc4e4d8529b2d9848fa4ce9" + } + } + } + }, + "expected": "reject", + "errorCode": "ADOPTION_REPLAY_MISMATCH", + "httpStatus": 409, + "detailsMustInclude": [ + "adoption.conflictOutcome" + ], + "reason": "Same adoptionKey with a different command/payload is never silently re-executed; the caller learns what the key actually committed." + }, + { + "name": "event-golden-valid", + "kind": "schema", + "targetSchema": "event-envelope.schema.json", + "expected": "accept", + "object": { + "schemaVersion": "coven.automations.v1", + "eventId": "evtocc05a7c3e9d24b6f8051", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "sequence": 5, + "recordedAt": "2026-08-30T09:00:05.000Z", + "observedAt": "2026-08-30T09:00:05.000Z", + "producer": { + "component": "coven-daemon", + "instanceId": "daemon@host-a" + }, + "automationId": "daily-notes", + "occurrenceId": "daily-notes-1756544400000", + "kind": "occurrence.transitioned", + "summary": "occurrence occurrence running -> succeeded", + "payload": { + "entity": "occurrence", + "from": "running", + "to": "succeeded", + "reason": "run_settled", + "fenceGeneration": 1 + }, + "privacy": { + "classification": "operational", + "retention": { + "classification": "standard" + } + } + } + }, + { + "name": "event-duplicate-delivery-is-ignored", + "kind": "changefeed", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "deliveries": [ + "event.occurrence.sequence[0]", + "event.occurrence.sequence[1]", + "event.occurrence.sequence[1]", + "event.occurrence.sequence[2]" + ], + "expected": "read model equals the fold of [0,1,2]; the redelivered eventId at index 2 is recognized and ignored, not re-applied", + "reason": "Delivery is at-least-once; consumers deduplicate on eventId." + }, + { + "name": "event-out-of-order-is-rejected-not-reordered", + "kind": "changefeed", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "consumerCursor": 2, + "deliveries": [ + "event.occurrence.sequence[1]" + ], + "expected": "reject", + "errorCode": "STREAM_OUT_OF_ORDER", + "reason": "sequence 1 is not strictly after the consumer cursor 2; consumers refuse regression against their cursor instead of re-applying history." + }, + { + "name": "event-replay-rehydrates-deterministically", + "kind": "changefeed", + "stream": { + "kind": "occurrence", + "id": "daily-notes-1756544400000" + }, + "reductions": [ + { + "label": "from-empty", + "cursor": -1, + "deliveries": "event.occurrence.sequence[0..5]" + }, + { + "label": "from-empty-with-duplicate", + "cursor": -1, + "deliveries": "event.occurrence.sequence[0..5] plus a duplicate of [3]" + }, + { + "label": "resume-after-cursor-2", + "cursor": 2, + "deliveries": "event.occurrence.sequence[3..5]" + } + ], + "expected": "all three reductions yield the identical read model: occurrence state succeeded with eventWindow.lastSequence 5", + "reason": "Duplicate delivery and reconnect (checkpoint resume) must rehydrate the same state; the occurrence's eventWindow.firstSequence/lastSequence bound the stream. cursor is the consumer's last applied sequence (-1 = empty); deliveries with sequence <= cursor are already folded." + } + ] +}