From 44b540ea31d7c85e1742c7acb4e9d10fedeac183 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 30 Aug 2026 12:24:01 -0400 Subject: [PATCH 1/6] docs(automations): record Coven Automations v1 program status (coven#854) (#861) Adds one dated status/decision record for issue #854 in docs/superpowers/plans/. Independently verified against main @ 1364cec (2026-08-30): the coven#816 foundation series (PR #846, PR #847, parts 6-8 commits) landed the durable local scheduler core - definitions, SQLite definition/occurrence/run records with unique occurrence fencing, RRULE planning, bounded leases with expiry recovery, latest-only misfire / overlap refusal, the 60s daemon tick with scheduled dispatch, shared launch path, health and run history, non-destructive paused legacy import, and coven.automations.* control actions. Records what is still absent for v1 (versioned protocol schemas, authority/receipt binding, deterministic-time contract, SDK surface, conformance/chaos/SLO gates, live Beads mutation), the gate-by-gate verdict, and the critical path. The program issue remains open; this record closes no issue. Refs OpenCoven/coven#854. Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- ...-30-coven-automations-v1-program-status.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-coven-automations-v1-program-status.md 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). From fb302f298ce714a19515a32d49e3224bcfe62610 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 30 Aug 2026 12:48:57 -0400 Subject: [PATCH 2/6] docs: plan cryptographic fresh-user and biometric assurance proofs (coven#815) (#871) * docs: plan cryptographic fresh-user and biometric assurance proofs (coven#815) Specify COVEN-ASSURANCE/1, an independently verifiable step-up proof so request authentication can satisfy FreshUserVerification/FreshBiometric grant requirements without trusting client-asserted assurance levels. The plan defines: possession/step-up key separation; binding the optional step-up authorization key and its declared assurance class into the pairing-v2 transcript; a separate authorization-key store; canonical proof bytes over device, grant, revocation epoch, exact request or DeviceActionIntent digest, server-issued single-use challenge, window, and requested assurance; server-side effective assurance computed as min(claimed, class ceiling) and passed to DeviceGrant::authorize; fail-closed possession-only degradation; rotation/revocation semantics; iOS/Android platform mapping; a self-verifying synthetic golden vector; and TypeScript/JSON Schema for implementers. Every claim cites the current mobile_memory code path it builds on. No code changes: this is the design artifact the issue asks for. Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> * docs(mobile): specify COVEN-ASSURANCE/1 step-up assurance proofs Add docs/design/mobile-assurance-step-up-v1.md, the plan and implementation contract for issue #815: a separately enrolled, platform-policy-protected step-up authorization key, bound into the pairing-v2 transcript before grant issuance, whose fresh signatures over canonical COVEN-ASSURANCE/1 bytes (device, grant, revocation epoch, exact request or DeviceActionIntent digest, single-use server challenge, validity window, requested assurance) are the only way to raise assurance above possession. The server verifies the signature, computes effective assurance itself, and passes it to DeviceGrant::authorize; absent/invalid/expired/replayed proofs fail closed. Covers storage separate from the possession key, replay protection independent of request nonces, rotation/revocation semantics, iOS/Android key-policy mapping, a security-invariant mapping, TypeScript types + JSON Schema, and a portable golden vector. Design artifact only; no behavior change. Refs OpenCoven/coven#815 Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --------- Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- docs/design/mobile-assurance-step-up-v1.md | 620 +++++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 docs/design/mobile-assurance-step-up-v1.md diff --git a/docs/design/mobile-assurance-step-up-v1.md b/docs/design/mobile-assurance-step-up-v1.md new file mode 100644 index 00000000..e3c92762 --- /dev/null +++ b/docs/design/mobile-assurance-step-up-v1.md @@ -0,0 +1,620 @@ +# Mobile Step-Up Assurance Proofs (`COVEN-ASSURANCE/1`) + +**Status:** plan / implementation contract for [#815](https://github.com/OpenCoven/coven/issues/815) +**Parent:** `#786` (generalized device grants) · Builds on `#791` (device grants) and `#812` (pairing v2) +**Authority owner:** Coven daemon / Rust authority layer (`crates/coven-cli/src/mobile_memory/`) +**Compatibility:** additive. Pairing v1/v2 requests without the extension are unchanged; devices without an enrolled authorization key keep exactly today's possession-only behavior. + +## Purpose + +The grant model can require `FreshUserVerification` or `FreshBiometric` +(`restrictions.require_fresh_user_verification_for`, `minimum_assurance` — +`crates/coven-cli/src/mobile_memory/grant.rs:73-103`), but request authentication +can never satisfy those requirements: `MobileAuthenticator::verify` presents the +constant `AssuranceLevel::Possession` to `DeviceGrant::authorize` +(`crates/coven-cli/src/mobile_memory/auth.rs:149`), because request +authentication only proves possession of the enrolled device key. Today, the +only way to express a stronger assurance level would be for a client to +*assert* it — and a client must never be able to upgrade assurance by sending +an asserted enum or string. + +This document specifies an independently verifiable **step-up proof**: a fresh +signature made by a separately enrolled, platform-policy-protected +*authorization key*, over canonical bytes that bind the proof to exactly one +device, grant, request (or `DeviceActionIntent`), challenge, and time window. +The server verifies the signature, recomputes everything it checks, computes +the effective assurance itself, and passes that value to +`DeviceGrant::authorize` (`crates/coven-cli/src/mobile_memory/grant.rs:157-194`). +No biometric material ever leaves the device; OpenCoven receives signatures +only. + +This plan is a design document, not an implementation PR: it defines the wire +bytes, state machines, storage, platform mapping, and portable vectors an +implementation must satisfy. No behavior change ships with this document. + +## Problem statement, in current code + +| Gap | Where today | +| --- | --- | +| `authorize` is always called with hardcoded `AssuranceLevel::Possession` | `crates/coven-cli/src/mobile_memory/auth.rs:149` and again in the post-response re-check at `auth.rs:174-181` | +| `minimum_assurance` can never exceed `Possession` in practice; `DeviceGrant::for_device` pins it | `crates/coven-cli/src/mobile_memory/grant.rs:119` | +| `AssuranceLevel` has no verifiable source: nothing but the server itself can mint anything above `Possession` | `crates/coven-cli/src/mobile_memory/grant.rs:50-58` | +| No second (step-up) key exists in the device record or registry schema | `crates/coven-cli/src/mobile_memory/registry.rs:26-33` | +| Replay protection exists only for ordinary request nonces, keyed `(device_id, nonce)` | `crates/coven-cli/src/mobile_memory/auth.rs:105,198-217` | +| The pairing-v2 transcript binds the device key but no authorization key | `crates/coven-cli/src/mobile_memory/pairing.rs:443-467` | + +Note that pairing v2 deliberately excluded biometric *authorization* from the +transcript (`docs/design/mobile-pairing-protocol-v2.md`, "Security properties", +item 6). That statement remains true: the biometric *ceremony* never enters the +transcript. What this spec adds is binding of the *authorization public key* +and its declared assurance class — an enrollment fact — which is a different +thing from any biometric data. + +## Key separation + +A paired device holds two distinct, non-exportable P-256 keys: + +| | Possession key | Step-up authorization key | +| --- | --- | --- | +| Identifies | the enrolled device | the authorization capability of that device | +| Enrolled via | pairing transcript (existing `devicePublicKey`, `crates/coven-cli/src/mobile_memory/pairing.rs:214-232`) | pairing transcript extension (below) | +| Unlocked for | every request (`COVEN-MEMORY/1`, `crates/coven-cli/src/mobile_memory/auth.rs:34-51`) | only sensitive actions, gated by platform policy | +| Stored as | `DeviceRecord.public_key_x963` + `grant.subject_key_id` (`registry.rs:26-33`, `grant.rs:242-250`) | separate authorization-key record (below) | +| iOS protection | none beyond secure key storage | `SecAccessControl` + LocalAuthentication policy | +| Android | Keystore, no user-authentication requirement | `BiometricPrompt` / strong-biometric policy | + +Rules: + +1. The two keys are distinct key pairs. A key MUST NOT serve both roles; the + registry MUST reject an authorization key equal to the device possession key. +2. The server stores only public keys and policy metadata. It never receives + biometric templates, hashes, or platform state, and does not infer platform + state beyond the assurance class declared for the enrolled key. +3. The declared class is an enrollment-time property of the key, enforced by + platform key policy on the device (see [Platform mapping](#platform-mapping)). + The server trusts the class only because it was confirmed in the pairing + transcript — never because a client asserted it at proof time. + +## Enrollment (binding the step-up key into the pairing-v2 transcript) + +Pairing v2's transcript (`COVEN-PAIR/2`, `crates/coven-cli/src/mobile_memory/pairing.rs:443-467`) +is extended with two optional, length-prefixed fields appended after +`app_version`, present exactly when the enrollment request carries them: + +1. raw canonical 65-byte step-up authorization public key (same P-256 X9.63 encoding as the possession key); +2. UTF-8 declared assurance class (`biometric_only` | `user_verification` | `device_credential`). + +Concretely, the closed enrollment request shape +(`crates/coven-cli/src/mobile_memory/contract.rs:237-246`, `deny_unknown_fields`) +gains one optional member: + +```json +{ + "protocolVersion": 2, + "pairingNonce": "", + "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. From fcc026c89749e767a69e0dc35085405028c80f8e Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 30 Aug 2026 13:27:03 -0400 Subject: [PATCH 3/6] docs: operationalize Coven Automations v1 tracker roadmap and drift check (coven#859) (#862) Establish the GitHub-side half of the #859 operational delivery graph: - docs/roadmaps/coven-automations-v1.md: program roadmap with canonical tracker roles, sync metadata, P0/P1/P2 policy, generated outcome-mapping table, dependency graph, release gates, active blockers, and drift detection usage. The mapping table block is generated by docs/roadmaps/drift-check.mjs --render and verified on every run. - docs/roadmaps/coven-automations-v1.mapping.json: machine-readable one-to-one Bead <-> GitHub mapping for #854, #816, #855, #856, #857, and #858, with explicit dependencies, acceptance gates, dispositions, and evidence links. Bead IDs stay null (warn-level W010) until provisioning lands through OpenCoven/coven-cave#5220 per the operational correction on #859; no competing Beads store is created in this repository. - docs/roadmaps/drift-check.mjs: credential-free, offline drift check covering state, priority, parent/dependency, evidence, generated-mirror, duplicate/missing mapping, cycle, and sensitive-payload drift classes, with --beads-export cross-checking and --selftest proving detection. - docs/superpowers/plans/2026-08-30-issue-859-coven-automations-v1-...md: dated status/decision record: what exists on main (automations series landed 2026-08-28 via PRs #846/#847), pre-change integrity report, decisions D1-D7, verdict against the #859 acceptance criteria, remaining work, critical path, and the initial evidence packet. Refs OpenCoven/coven#859 fix: keep automation tracker docs scanner-safe Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- .../coven-automations-v1.mapping.json | 206 +++++ docs/roadmaps/coven-automations-v1.md | 181 +++++ docs/roadmaps/drift-check.mjs | 727 ++++++++++++++++++ ...tomations-v1-tracker-operationalization.md | 203 +++++ 4 files changed, 1317 insertions(+) create mode 100644 docs/roadmaps/coven-automations-v1.mapping.json create mode 100644 docs/roadmaps/coven-automations-v1.md create mode 100644 docs/roadmaps/drift-check.mjs create mode 100644 docs/superpowers/plans/2026-08-30-issue-859-coven-automations-v1-tracker-operationalization.md 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/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. From 957715fb8c771444ef074de4fcaf9787341e8a7c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 30 Aug 2026 13:48:37 -0400 Subject: [PATCH 4/6] docs: consolidate Coven security policy, threat boundary, and support claims * docs: consolidate Coven security policy, threat boundary, and support claims Replace the duplicated SECURITY.md (repository policy plus a copied organization-wide disclosure addendum) with one Coven-specific operational security contract that visibly separates: - supported release family and security-supported surfaces, with experimental/disabled surfaces (AFS mount backend, OpenClaw plugin, remote transports) marked explicitly; - enforced properties today, each tied to its normative contract and verification family (Rust authority checks, capability advertisement, pre-effect validation, owner-protected local transport, redaction and artifact defaults, secret/privacy guards, explicit mutation replay); - residual risk and safe configuration (same-user trust vs sandboxing, raw-artifact opt-in, retention limits, untrusted harnesses, AgentFS mount posture synced with #779); - private reporting via GitHub Security Advisories as the primary path, with no personal-channel dependency and no unsupported response-time commitment, plus scoped third-party dependency/provider handling; - design goals (session/memory/agent isolation) moved out of guarantees, cross-referencing #803/#804 instead of claiming unimplemented A2A isolation. Removes the stale OpenTrust-scope addendum, the personal Discord DM reporting path, and the 48-hour/14-day response promises that no accountable process currently backs. Refs OpenCoven/coven#808 Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> * fix: document tokenized invite URL privacy rule Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --------- Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- SECURITY.md | 319 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 215 insertions(+), 104 deletions(-) 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* From de85cf46a63dff91703a9687c294bac7310aad7c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:44:59 +0000 Subject: [PATCH 5/6] docs(pairing): plan TUI QR bootstrap and E2EE mobile pairing (refs #785) Add the implementation plan for issue #785: the coven device command family, canonical CBOR pairing offer with Universal Link encoding, Noise_XK handshake with QR-pinned host authentication, rendezvous relay MVP on coven-relay, six-word human verification derived from the handshake hash, scope selection and capability binding, the adversarial test matrix, and the maintainer decision points. Extends the accepted mobile-device-trust architecture and elaborates PR 3 + the rendezvous slice of PR 4 of the pairing delivery train. Docs-only change; cross-links the delivery plan to the new plan. Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com> --- .../mobile-device-pairing-delivery-plan.md | 2 + ...obile-device-pairing-tui-bootstrap-plan.md | 777 ++++++++++++++++++ 2 files changed, 779 insertions(+) create mode 100644 docs/architecture/mobile-device-pairing-tui-bootstrap-plan.md 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 Date: Sun, 30 Aug 2026 15:57:17 +0000 Subject: [PATCH 6/6] chore(pairing): retrigger CI for the plan PR (no content change) The pull_request opened event raced the fork's lazy workflow registration, so no run was created. This empty commit re-emits the synchronize event; it changes no content. Signed-off-by: Timothy Wayne Gregg <5861166+CompleteDotTech@users.noreply.github.com>