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
118 changes: 115 additions & 3 deletions specs/PHASE-4-CAVE-SURFACES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
**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)

**Phase 5 amendment (2026-07-20, bead `threads-uqx.7`):** scheduled
proposal lifecycle metadata comes from the daemon read model added by
OpenCoven/coven#430. Cave may combine that metadata with staged-file contents
for inspection, but it never infers lifecycle transitions from clocks or local
files.

---

## 0. Ground rules (normative, inherited — violating any of these is wrong by declaration)
Expand Down Expand Up @@ -221,10 +227,92 @@ never dropped from the list silently (§4.R7).
{ "surface": "MEMORY.md", "contents": { "encoding": "utf8|base64", "data": "…full desired contents, never diffs…" } }
],
"stagedAt": "…"
}
},
"authority": { /* ProposalAuthorityView, below */ }
}
```

Phase 5 adds daemon-owned scheduling metadata without making Cave an approval
engine. The daemon adapter joins each parse-ok staged file to
`GET /api/v1/threads/proposals` by proposal id. Staged contents remain visible
for principal inspection; every lifecycle field and display label comes from
the daemon response. `proposalRevision` commits the complete authority envelope,
including staged contents. `familiarUuid` is compared directly with the staged
payload; the daemon's human `familiarId` remains display data and is not used as
the identity join key.

Revision algorithm (normative): remove top-level procedural
`decisionRequest`/`decisionState`; recursively sort every JSON object by Unicode
code-point key order while preserving array order; serialize compact UTF-8 JSON;
then lowercase-hex SHA-256 the bytes. Cave computes this revision from the same
parsed envelope used to render edits and requires equality with the daemon's
`proposalRevision`. A mismatch blocks the card before any action is offered.

```jsonc
// Scheduled proposal whose daemon metadata is verified.
{
"state": "verified",
"proposalRevision": "64-char lowercase hex", // daemon commitment to the complete authority envelope
"familiarUuid": "familiar-uuid", // canonical join key for the staged payload
"approvalPath": {
"variant": "auto-regression|familiar-coherence|human-approval|human-approval-with-rationale",
"label": "auto|familiar_review|human_review|human_required", // daemon text, rendered verbatim
"vetoDeadline": "RFC3339 | null"
},
"lifecycle": "awaiting-human-approval|veto-window-open|ready-for-replay|blocked",
"blockedReason": "string | null",
"earliestClose": "RFC3339 | null",
"affectedRegions": ["tool_defaults"],
"availableDecisions": ["approve", "reject"] // deterministic mapping below
}

// Pre-Phase-5 proposal. Compatibility only; no scheduled lifecycle is invented.
{ "state": "legacy", "reviewKind": "authority" }

// Scheduled envelope exists, but daemon authority cannot be joined or verified.
{
"state": "blocked",
"why": "daemon-unavailable|daemon-proposal-missing|daemon-unparseable|daemon-mismatch|unknown-lifecycle"
}
```

`availableDecisions` is a closed presentation mapping over verified daemon
fields, not local authorization:

The daemon wire uses snake_case enum values and nested field names. The adapter
performs only this closed normalization before the mapping:

| Daemon wire | Cave view |
|---|---|
| `auto_regression` | `auto-regression` |
| `familiar_coherence` | `familiar-coherence` |
| `human_approval` | `human-approval` |
| `human_approval_with_rationale` | `human-approval-with-rationale` |
| `awaiting_human_approval` | `awaiting-human-approval` |
| `veto_window_open` | `veto-window-open` |
| `ready_for_replay` | `ready-for-replay` |
| `blocked` | `blocked` |
| `approvalPath.veto_deadline` | `approvalPath.vetoDeadline` |

Any value outside this table is `unknown-lifecycle`; no generic case conversion
or label synthesis is permitted. `approvalPath.label` is never normalized.

| Daemon lifecycle and path | Cave action |
|---|---|
| `awaiting-human-approval` + `human-approval` | approve or reject |
| `awaiting-human-approval` + `human-approval-with-rationale` | approve with a non-empty note, or reject |
| `veto-window-open` + a path carrying a veto deadline | reject, rendered as **Veto** |
| `ready-for-replay`, `blocked`, unknown combination, or blocked authority | no action |

Cave MUST NOT compare its clock to `vetoDeadline` or `earliestClose` to change
the action set. Deadline expiry triggers daemon replay; only a fresh daemon
response may move lifecycle. Every POST remains a forwarder and the daemon
re-validates the decision. Approve/reject bodies carry
`expectedRevision: authority.proposalRevision`; the daemon rejects a changed or
missing revision before accepting a manual Phase 5 decision. This binds the
principal's action to the exact proposal contents inspected, not merely its id
and target list.

### 2.7 `DegradedFamiliarView` — a familiar whose ward config cannot be parsed

Amendment 2026-07-18 (bead `threads-k9s`, follow-up to `threads-vmf`/`threads-v60`).
Expand Down Expand Up @@ -269,7 +357,9 @@ never apply edits, never touch `pending/`, never write sqlite.

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

- Body: `{ "note": "optional principal note" }`. `readsJson: true`,
- Body:
`{ "note": "optional principal note", "expectedRevision": "daemon-issued proposal revision" }`.
`expectedRevision` is mandatory for manual Phase 5 decisions. `readsJson: true`,
`invalidJson: "guarded"`.
Comment on lines +360 to 363
- `daemon-present`: forward to the daemon socket; the daemon re-validates
(staging is data, not authority — replay goes back through `validate`,
Expand All @@ -282,6 +372,15 @@ never apply edits, never touch `pending/`, never write sqlite.
- 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).
- Phase 5 scheduled proposals expose only `authority.availableDecisions`.
Unknown, stale, unavailable, mismatched, or unrecognized daemon lifecycle
metadata disables both actions. Cave never substitutes a local default.
- A veto uses the existing reject forwarder. The UI label changes to **Veto**
only when the verified daemon lifecycle is `veto-window-open`; the request is
still `POST /api/proposals/[id]/reject`.
- `human_required` approval requires a non-empty `note` before Cave enables the
approve button. The daemon remains authoritative and rejects missing
rationale even if the route is called directly.

### 3.8 Response envelope — freshness on every response

Expand Down Expand Up @@ -334,6 +433,10 @@ approval actions disabled, evidence trace still reachable.
| 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 |
| R12 | Familiar ward config unparseable | §2.7 `degraded` entry in weaves response | Blocked rail row named for the familiar, "ward unreadable — protection not verifiable" copy, sanitized error in trace, zero threads shown, all actions disabled; never silently absent (added 2026-07-18) |
| R13 | Scheduled proposal lacks daemon lifecycle metadata | staged file has Phase 5 schema but daemon join is absent/unavailable | Proposal remains inspectable with blocked authority; both actions disabled |
| R14 | Daemon proposal metadata disagrees with staged authority | id, canonical `familiarUuid`, writer, staged time, target set, or committed proposal contents mismatch | Blocked `daemon-mismatch`; no locally preferred source |
| R15 | Unknown lifecycle/path combination | unrecognized enum value or invalid combination | Blocked `unknown-lifecycle`; never fall back to legacy actions |
| R16 | Scheduled proposal metadata stale | response is past `staleAfter` | Last-known lifecycle may render as trace only; both actions disabled until a fresh daemon response |

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

Expand Down Expand Up @@ -371,6 +474,15 @@ need; Phase 5+.
- [ ] 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)
- [ ] No code path renders healthy from unverifiable input (R1–R16 sweep)
- [ ] POST routes forward-only; grep-level check: no `fs.write`/`unlink` under the proposals routes, no sqlite writes anywhere in Cave API
- [ ] Phase 5 fixtures cover `awaiting-human-approval`,
`veto-window-open`, `ready-for-replay`, and `blocked`, plus unavailable,
mismatched, and unknown daemon metadata
- [ ] Cave renders daemon approval labels verbatim and never advances lifecycle
from its own clock
- [ ] Every manual Phase 5 decision forwards the daemon-issued
`proposalRevision`; missing or changed revisions fail closed
- [ ] `availableDecisions` follows the §2.6 closed mapping; rationale-required
approval stays disabled until a non-empty note exists
- [ ] `pnpm typecheck`, `pnpm test:app`, `pnpm test:api` green