Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
352 changes: 352 additions & 0 deletions specs/PHASE-4-CAVE-SURFACES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
# PHASE-4 — Cave Surfaces: the data contract between daemon memory state and the Coven Cave UI

**Status:** DRAFT (bead `threads-986.17.1` — written first, before any UI code; blocks `.17.2`–`.17.9`)
**Date:** 2026-07-15
**Upstream:** RFC-0001 §5 (Ward: gates, tiers, audit); `PHASE-0-DESIGN.md` (FROZEN v0.2) §2 metaphor bindings, §2.5 binding rule, §3.4 single audit store, §5 verdicts
**Substrate:** `coven-threads-core` v0.1.2 (98 tests green) + coven daemon integration branch `feat/threads-gate-validator` (PR OpenCoven/coven#382, merge gated on `threads-986.19`)
**Shape reference:** OpenTrust `docs/MEMORY-API-CONTRACT.md` / `docs/MEMORY-LAYER-STANDARD.md` — mirrored in *shape* (contract-first, evidence-over-summaries, freshness surfaced everywhere), **never in stack** (no Tauri, no Convex, no plugin architecture, no vendored code)

---

## 0. Ground rules (normative, inherited — violating any of these is wrong by declaration)

1. **Predicate authoritative, descriptor derived.** The UI MUST NOT present a
`PatternDescriptor` (or any `describe()`-derived summary) as if it were
enforcement. Descriptor content is always labeled derived. Every displayed
*status* traces to a predicate result delivered by the read adapter.
2. **The UI never mutates protected memory.** All writes flow through the
daemon. The Cave reads state and approves/rejects **staged** proposals only,
by forwarding the decision to the daemon. No route in this contract writes
to a protected surface, to `~/.coven/pending/`, or to `coven.sqlite3`.
3. **Gate 4 fail-closed is a rendering rule here.** Unknown, unverifiable, or
daemon-absent state renders as **blocked / fail-closed** — never
healthy-by-default. §4 enumerates every such state. A UI that shows a green
pill it cannot trace to evidence is non-conformant.
4. **Metaphor-referent binding (§2.5) applies one layer up.** *Thread* =
authority relationship surface → writer; *Weave* = enforced pattern of
threads; *Strand* = fiber inside a thread; *Channel* = axis of load. UI copy
uses these words only with their referents attached (§5 glossary). Nova
reviews for code-to-UI coherence (blocking, `threads-986.17.8`).
5. **Implementation home is `coven-cave`, following `coven-cave` conventions**:
every route registered in `src/app/api/api-contracts.test.ts`; every test
file registered in `scripts/run-tests.mjs` `SUITES`; `pnpm typecheck`,
`pnpm test:app`, `pnpm test:api` green before ready-for-review.

## 1. Sources of truth the Cave reads

| Source | Owner | What it holds | Read by |
|---|---|---|---|
| Weave state (via daemon; fixture mirror until `.19` merges) | daemon (`threads_gate.rs`) | `Weave` / `Thread` / `Strand` / `TensionState` per familiar | weave rail, thread pane, strand inspection |
| `ward_audit` table in `coven.sqlite3` | daemon (append-only; UPDATE/DELETE abort triggers) | one row per audit event (RFC-0001 §5.6 field set + coven-threads extensions) | audit lineage view, thread pane history |
| `~/.coven/pending/*.json` | daemon (atomic sibling-rename writes) | `PendingProposal` staged by `DegradeToProposal` | proposal approval flow |
| Daemon socket `~/.coven/coven.sock` | daemon | live API incl. additive `threadsGate` payload; approve/reject forwarding target | adapter `daemon-present`; POST forwarders |

**Two adapters, one interface (`.17.2`):**

- `daemon-absent` — reads fixtures from `coven-cave/fixtures/phase-4/`.
**Default** until `threads-986.19` merges PR #382. In this mode every
response carries `meta.adapter = "fixtures"` and the approval POST routes
refuse (§3.7) — there is no daemon to forward to, so the action fails
closed.
- `daemon-present` — reads the coven socket and the sqlite audit table.
Same read-model shapes, `meta.adapter = "daemon"`.

Fixture states (minimum set, all ten): `weave-holds`, `weave-frayed`,
`weave-snapped`, `weave-unknown`, `daemon-timeout`, `audit-empty`,
`audit-with-lineage`, `pending-empty`, `pending-with-proposals`,
`pending-corrupt`.

## 2. Read models

Read models are the UI-facing JSON the Cave API returns. Adapters normalize
raw source encodings (serde externally-tagged enums, sqlite rows, staged JSON)
into these shapes; the raw form stays available under `trace` so every status
pill can open its evidence (OpenTrust trace-detail shape).

Conventions: camelCase keys; timestamps RFC 3339 strings; hashes/binary
lowercase hex strings; ids are strings (UUID or human-readable, as the source
defines them).

### 2.1 `TensionView`

The crate's `TensionState` has exactly three states. The *view* adds the two
UI-only fail-closed states — `unknown` arises when the adapter cannot fetch or
verify, `stale` when `staleAfter` has passed (§3.9). Neither exists in the
crate and neither may be persisted back toward it.

```jsonc
// state: "holds" | "frayed" | "snapped" | "unknown" | "stale"
{ "state": "holds" }

{
"state": "frayed",
"strand": "9f0c…-uuid | null", // null = a required strand is missing
"channel": "deliberate|forced|serialization|mutation",
"reason": { // FrayReason, normalized
"kind": "content-hash-mismatch|signature-invalid|manifest-entry-mismatch|audit-trail-unverifiable|required-strand-missing|serialization-marker-mismatch|other",
"missingKind": "ContentHash|Signature|ManifestEntry|AuditTrail|SerializationMarker", // only for required-strand-missing
"detail": "string" // only for other
},
"detectedAt": "2026-07-15T09:00:00Z"
}

{
"state": "snapped",
"channel": "…",
"reason": { "kind": "revoked|multiple-strand-fray|pattern-broken|other", "detail": "…" },
"at": "2026-07-15T09:00:00Z"
}

{ "state": "unknown", "why": "daemon-unreachable|unparseable|no-fixture|meta-missing" }
{ "state": "stale", "lastKnown": { "state": "holds" }, "observedAt": "…" }
```

### 2.2 `WeaveSummary` and `WeaveDetail`

```jsonc
// WeaveSummary — the rail row
{
"id": "weave-uuid",
"familiarId": "sage", // human-readable id, as ward_audit stores it
"threadCount": 4,
"tensionRollup": { "state": "frayed", "…": "worst contained thread state; §4.R1" },
Comment on lines +108 to +112
"coherence": "coherent|degraded|broken|unknown", // predicate result — never the descriptor
"degradedSurfaces": ["MEMORY.md"], // present iff degraded
"weaveHash": "hex"
}

// WeaveDetail — adds:
{
"…": "all WeaveSummary fields",
"threads": [ /* ThreadView, §2.3 */ ],
"patternDescriptor": { // derived — render only with the derived label (§4.R2)
"derived": true,
"summary": "…verbatim from describe()…"
},
"covenRef": "coven-uuid | null"
}
```

`tensionRollup` severity order (worst wins): `snapped` > `frayed` > `unknown`
> `stale` > `holds`. An unknown thread can never be out-ranked by a healthy
one — unknown is *worse* than holds by construction (fail-closed).

### 2.3 `ThreadView`

```jsonc
{
"id": "thread-uuid",
"surface": "SOUL.md", // SurfaceId — the protected surface
"writer": "familiar:sage", // WriterId — who may propose writes
"tension": { /* TensionView */ },
"holdsUnder": ["mutation", "forced", "serialization"], // covered channels
"requiredStrands": { // per covered channel: Channel::required_strand_kinds
"mutation": ["ContentHash"],
"forced": ["ContentHash", "ManifestEntry"],
"serialization": ["SerializationMarker"]
},
"strandCount": 3,
"createdAt": "…"
}
```

### 2.4 `StrandView`

One shape per kind; `kind` discriminates. Committed material renders as hex.

```jsonc
{ "id": "…", "kind": "ContentHash", "algorithm": "blake3|sha256", "value": "hex" }
{ "id": "…", "kind": "Signature", "keyId": "…", "sigKind": "ed25519|principal-attestation", "value": "hex" }
{ "id": "…", "kind": "ManifestEntry", "manifestId": "…", "entryHash": "hex" }
{ "id": "…", "kind": "AuditTrail", "firstSeen": "…", "eventLogRef": "…" }
{ "id": "…", "kind": "SerializationMarker", "formatVersion": "0.1.0", "contractHash": "hex" }
```

On a Frayed thread, the strand the fray blames additionally carries the
current-vs-expected diff (`.17.5`):

```jsonc
{
"…": "StrandView fields",
"fray": {
"expected": "hex | string", // committed value from the strand
"observed": "hex | string | null", // what the verifier saw; null = could not observe (renders blocked, not healthy)
"observedAt": "…"
}
}
```

### 2.5 `AuditEntryView` — one `ward_audit` row

Mirrors the append-only table (`WARD_AUDIT_SCHEMA_SQL`; RFC-0001 §5.6 set +
`validation_verdict` + `compaction_ledger`).

```jsonc
{
"id": 412, // rowid — also the lineage cursor
"eventType": "proposal_submitted|proposal_approved|proposal_rejected|proposal_vetoed|ward_updated|validation_verdict|compaction_ledger",
"proposalId": "uuid | null",
"familiarId": "sage",
"wardVersion": "string | null",
"wardHash": "hex", // weave_hash at decision time — binds decision to authority state
Comment on lines +187 to +191
"tier": "string | null",
"decision": "permit | degrade_to_proposal | reject:<reason-tag> | …",
"approver": "writer-id | null",
"diffHash": "hex | null",
"filesTouched": ["SOUL.md"],
"channel": "mutation | null",
"threadId": "uuid | null",
"submittedAt": "…", "decidedAt": "…", "recordedAt": "…"
}
```

Lineage rule: an entry whose `proposalId`/`threadId` cannot be resolved to a
live object renders with an explicit unresolved-reference marker — it is
never dropped from the list silently (§4.R7).

### 2.6 `ProposalView` — one `~/.coven/pending/*.json` file

```jsonc
{
"file": "<familiar-uuid>-<proposal-uuid>.json",
"parse": "ok | corrupt", // corrupt ⇒ payload null, §4.R6 applies
"payload": { // PendingProposal, normalized
"id": "proposal-uuid",
"familiarId": "familiar-uuid",
"writer": "familiar:sage",
"channel": "mutation",
"threadId": "thread-uuid",
"fray": { /* FrayOrSnap verbatim for the principal, normalized like TensionView.reason */ },
"edits": [
{ "surface": "MEMORY.md", "contents": { "encoding": "utf8|base64", "data": "…full desired contents, never diffs…" } }
],
"stagedAt": "…"
}
}
```

## 3. API routes

All routes live in `coven-cave` `src/app/api/`, are registered in
`api-contracts.test.ts`, and return the envelope of §3.8. GET routes are
`kind: "json"`. The two POST routes are thin daemon-forwarders — they carry
the principal's decision to the daemon and return the daemon's outcome; they
never apply edits, never touch `pending/`, never write sqlite.

| # | Route | Method | Returns |
|---|---|---|---|
| 1 | `/api/weaves` | GET | `WeaveSummary[]` — optional `?familiar=<id>` filter |
| 2 | `/api/weaves/[id]` | GET | `WeaveDetail` |
| 3 | `/api/threads/[id]` | GET | `ThreadView` |
| 4 | `/api/threads/[id]/strands` | GET | `StrandView[]` (with `fray` diff blocks where blamed) |
| 5 | `/api/threads/[id]/audit` | GET | `AuditEntryView[]` for this thread, newest first; `?before=<rowid>` pagination |
| 6 | `/api/proposals` | GET | `ProposalView[]` (both `ok` and `corrupt` entries) |
| 7 | `/api/proposals/[id]/approve` | POST | daemon outcome verbatim + envelope (§3.7) |
| 8 | `/api/proposals/[id]/reject` | POST | daemon outcome verbatim + envelope (§3.7) |

### 3.7 Approve/reject semantics (fail-closed)

- Body: `{ "note": "optional principal note" }`. `readsJson: true`,
`invalidJson: "guarded"`.
- `daemon-present`: forward to the daemon socket; the daemon re-validates
(staging is data, not authority — replay goes back through `validate`,
RFC-0001 §5.4), applies or refuses, appends `proposal_approved` /
`proposal_rejected` to `ward_audit`, and removes the pending file. The
route returns the daemon's outcome verbatim.
- `daemon-absent` (fixtures mode) or daemon unreachable/timeout: **HTTP 503**,
`{ "blocked": true, "why": "daemon-unavailable" }`. The UI renders the
§4.R5 blocked treatment. No optimistic UI, no queued decisions.
- Proposal `parse: corrupt`: both actions disabled in the UI *and* the routes
answer **HTTP 409** `{ "blocked": true, "why": "proposal-corrupt" }` if
called anyway (§4.R6).

### 3.8 Response envelope — freshness on every response

Every response (success or blocked) is wrapped:

```jsonc
{
"data": …, // route-specific read model, or null when blocked
"meta": {
"observedAt": "2026-07-15T09:00:00Z", // when the adapter observed the source
"staleAfter": "2026-07-15T09:00:30Z", // when this observation stops being trustworthy
"sourceCursor": "ward_audit:412 | weave:<weave_hash-hex> | pending:<dir-content-hash>",
"adapter": "daemon | fixtures",
"verified": true // false ⇒ §4.R8: render blocked
},
"blocked": false // true ⇒ data null, why present
}
```

`sourceCursor` is the monotonic (or content-addressed) identifier of the
source state backing the response: max `ward_audit` rowid for audit reads,
`weave_hash` for weave/thread/strand reads, a content hash of the pending
directory listing for proposals. Honest staleness beats fresh-looking lies:
adapters never fabricate `observedAt`.

### 3.9 Staleness

A client (or test) holding a response past `staleAfter` MUST render the §2.1
`stale` state: banner + last-known state, actions disabled. Stale never
silently re-renders as healthy; only a fresh fetch with a newer
`observedAt` clears it.

## 4. Fail-closed rendering rules (complete enumeration)

Every row is a test fixture in `.17.2` and a rendering test in `.17.3`–`.17.6`.
"Blocked" = the blocked pill/banner treatment: state is visibly not-healthy,
approval actions disabled, evidence trace still reachable.

| # | State | Detection | Rendering (never healthy-by-default) |
|---|---|---|---|
| R1 | Thread/weave tension unknown | adapter returns `state: "unknown"` | Blocked pill; rollup treats unknown as worse than holds |
| R2 | Descriptor without predicate | `patternDescriptor` present, `coherence` unknown | Descriptor renders **only** with derived label; status area blocked |
| R3 | Daemon timeout | adapter timeout in `daemon-present` | Stale banner + last-known state; POST actions disabled |
| R4 | Daemon absent, no fixtures | neither source resolvable | Full-surface blocked state, "cannot verify" copy |
| R5 | Approve/reject while daemon unavailable | POST returns 503 | Action fails visibly; decision NOT queued; proposal stays pending |
| R6 | Corrupt pending file | JSON parse failure | Listed as `corrupt`, raw bytes inspectable, both actions disabled (409 if forced) |
| R7 | Audit entry with unresolvable refs | `proposalId`/`threadId` lookup fails | Entry shown with unresolved-reference marker; never silently dropped |
| R8 | Envelope missing/unverified `meta` | `meta` absent or `verified: false` | Contract violation ⇒ blocked; log loudly in dev |
| R9 | Stale response | now > `staleAfter` | §3.9 stale treatment |
| R10 | Snapped thread | `state: "snapped"` | Terminal treatment: read-only, "fresh authority ceremony required" copy; no repair affordance |
| R11 | Unknown route/id (404s) | id not in source | 404 with envelope, `blocked: true`; UI renders not-found as blocked, not empty-healthy |

Rollup arithmetic (R1, normative): `snapped > frayed > unknown > stale > holds`.

## 5. Metaphor-referent glossary for UI copy

Scoped to UI surfaces; each line is the binding UI copy must preserve
(`docs/glossary.md` is the full reference). Charm's voice pass (`.17.7`)
rewrites *tone*, never *referents*; Nova's review (`.17.8`) blocks on drift.

- **Weave** — this familiar's enforced pattern of threads; what the rail lists. Coherent iff its pattern predicate holds.
- **Thread** — one authority relationship, *surface → writer*; what a pane row is. Never "a conversation".
- **Strand** — one fiber of commitment inside a thread (hash, signature, manifest entry, audit anchor, serialization marker); what inspection shows.
- **Channel** — the axis of load a mutation arrives through: deliberate, forced, serialization, mutation.
- **Tension** — a thread's standing under load: **Holds** (full contract), **Frayed** (one strand failed; repairable; must surface to you), **Snapped** (terminal; fresh authority ceremony required).
- **Proposal** — a staged write awaiting the principal's decision; data, not authority.
- **Blocked** — the fail-closed rendering of anything unknown or unverifiable; honest, not alarming.
- **Derived** — descriptor-sourced summary for legibility; never what decided.
- **Trace** — the predicate evidence behind a status pill; every pill opens one.

Voice (for `.17.7`): patient, precise, evidence-first. Copy states what is
verified, what is not, and what the principal can do — it never reassures
beyond the evidence and never dramatizes a fray.

## 6. Out of scope (refuse politely, file a follow-up bead)

General policy engine; `.af` compatibility; replacing the daemon trust
boundary; any UI write path bypassing the daemon; Shape A/B portability
decision (`threads-986.16`); OpenTrust code vendoring or its Tauri/Convex
packaging; redesigning `coven-cave`'s shell beyond what the four surfaces
need; Phase 5+.

## 7. Conformance checklist for `.17.2`–`.17.6` closes

- [ ] Every route in §3 registered in `api-contracts.test.ts` with correct methods/kind/guards
- [ ] Both adapters answer every route with the §3.8 envelope
- [ ] All ten fixture states exist and are exercised
- [ ] Every §4 row has a test proving its rendering
- [ ] No code path renders healthy from unverifiable input (R1–R11 sweep)
- [ ] POST routes forward-only; grep-level check: no `fs.write`/`unlink` under the proposals routes, no sqlite writes anywhere in Cave API
- [ ] `pnpm typecheck`, `pnpm test:app`, `pnpm test:api` green