diff --git a/specs/capture-bdd-spec.md b/specs/capture-bdd-spec.md index 48a0f4e..345d155 100644 --- a/specs/capture-bdd-spec.md +++ b/specs/capture-bdd-spec.md @@ -1,6 +1,6 @@ # Capture capability — use-case & BDD specification -**Status:** Draft for review (pre-build). Spec precedes implementation because `capture` is a +**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a privileged cross-harness write path touching brain + Gitea + ai-sessions. **Tracks:** hyperguild #49. **Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in @@ -22,8 +22,10 @@ behaviour across harnesses. 1. Caller assembles capture input (insights, tickets, optional summary) + context (harness, session_ref, fidelity, actor, **data-classification**). 2. System validates the whole request (fail-closed). -3. System checks the **sovereignty gate** (I1): if the session is classified confidential AND the - caller's harness is a non-sovereign (us-nexus) surface, the capture is **refused** at this layer. +3. System resolves **effective classification** (stricter of caller-declared and target-derived) + and the **server-derived harness origin** (from the authenticated principal). It checks the + **sovereignty gate** (I1): if effective classification is confidential AND the origin is a + non-sovereign (us-nexus) surface, the capture is **refused** before any write. 4. System persists insights (write or supersede), tickets (create/close/comment), summary — each best-effort, recording per-item outcome. 5. System emits an **audit record** (I5) of who/what captured what, when, via which principal. @@ -41,7 +43,7 @@ beyond the I5 audit log. | Invariant | Obligation on `capture` | |---|---| -| **I1 sovereign containment** | A confidential-classified session MUST NOT be captured through a us-nexus harness. Classification is explicit in `context`, enforced here, not assumed. | +| **I1 sovereign containment** | A confidential-classified session MUST NOT be captured through a us-nexus harness. Harness origin is **server-derived from the authenticated principal** (not caller-asserted). Classification uses **model (C)**: caller declares, server cross-checks the target's tag, **stricter wins**, mismatch logged. See §4.1–4.2. | | **I2 deliberate acceptance** | The *distributed-library* form opens no new acceptance. IF a central relay node is deployed, its cross-harness reach MUST be entered in `infra/docs/security-baseline.md` with Why-accepted / Revisit-if before it ships. | | **I3 GitOps reconcilability** | IF `capture` runs as a deployed service, its manifest lives under `infra/k3s/apps/**` (sovereign source, Flux-reconciled). No untracked runtime. | | **I4 decisions captured** | The distributed-vs-central decision and the intent-named-verb pattern are recorded (ADR + brain). | @@ -73,20 +75,46 @@ Feature: Capture session value uniformly across harnesses And the receipt reports every item as ok # --- I1: sovereignty gate (the load-bearing refusal) --- + # Harness origin is server-derived from the authenticated principal, never from context.harness. Scenario: Refuse capture of a confidential session through a us-nexus harness - Given a session classified as "confidential" - And the calling harness is a us-nexus surface + Given a session whose effective classification is "confidential" + And the authenticated principal resolves to a us-nexus harness origin When capture is invoked Then the capture is refused before any write And no insight, ticket, or summary is persisted And the refusal names the sovereignty invariant as the reason Scenario: Allow capture of a confidential session through a sovereign harness - Given a session classified as "confidential" - And the calling harness is a sovereign-soil surface + Given a session whose effective classification is "confidential" + And the authenticated principal resolves to a sovereign-soil harness origin When capture is invoked Then the capture proceeds and persists normally + Scenario: Ignore a caller-asserted harness label and use the server-derived origin + Given the request context asserts harness "sovereign-soil" + But the authenticated principal resolves to a us-nexus origin + And the session classification is "confidential" + When capture is invoked + Then the capture is refused + And the server-derived origin is used, not the asserted label + And the asserted-vs-derived discrepancy is logged as a security event + + # --- I1: classification model (C) — stricter of declared vs target-derived wins --- + Scenario: Take the stricter classification when caller and target disagree + Given the caller declares classification "internal" + But the target wing/repo is tagged "confidential" + When capture is invoked + Then the effective classification is "confidential" + And the declared-vs-derived mismatch is logged as a security event + And the I1 gate is evaluated against "confidential" + + Scenario: Honour a caller raising sensitivity above the target's tag + Given the caller declares classification "confidential" + And the target wing/repo is tagged "internal" + When capture is invoked + Then the effective classification is "confidential" + And the capture is gated as confidential + # --- Supersession + staleness discipline (reuses #45 / #47 resolution) --- Scenario: Supersede a prior insight rather than duplicating it Given an insight whose context names an existing note to supersede @@ -119,13 +147,28 @@ Feature: Capture session value uniformly across harnesses Then the would-be receipt is returned And nothing is written anywhere - # --- I5: auditability is non-optional --- - Scenario: Capture is unavailable if the audit sink is unreachable - Given the audit substrate cannot be written to + # --- I5: auditability is non-optional, but degrades rather than blocks --- + Scenario: Capture proceeds under a durable local audit buffer when the central sink is down + Given the central audit substrate (loki) cannot be written to + When capture is invoked + Then the capture proceeds + And the audit record is written to a durable LOCAL fallback buffer + And an ntfy alert is emitted naming the degraded audit state + And the receipt flags that audit was buffered locally, not centrally recorded + # Never UN-audited: the write is buffered durably and reconciled on recovery. + + Scenario: Locally buffered audit records reconcile to the central sink on recovery + Given audit records were buffered locally during a sink outage + When the central audit substrate becomes reachable again + Then the buffered records are replayed to the central sink + And the local buffer is cleared only after confirmed central write + + Scenario: Capture refuses if even the local audit buffer cannot be written + Given neither the central sink nor the local fallback buffer can be written When capture is invoked Then the capture is refused And the reason names the auditability invariant - # A privileged write path that cannot be audited must not run silently. + # Degrade-and-warn has a floor: if NOTHING can record the audit, do not write. # --- Summary fidelity (collision rule from the retro work) --- Scenario: A richer-fidelity summary supersedes a thinner one for the same session @@ -138,18 +181,43 @@ Feature: Capture session value uniformly across harnesses --- -## 4. Open questions for review (deliberately unresolved here) +## 4. Resolved decisions (2026-06-22) -1. **Classification source.** How does `context.classification` get set, and is it trusted? A - harness self-declaring "internal" to bypass the I1 gate is an attack surface. Options: caller - declares + server cross-checks against a project/repo allow-list; or classification derived from - the target wing/repo rather than caller-asserted. **This is the highest-risk open question** — - the whole I1 gate is only as strong as the classification it reads. -2. **Sovereign-harness determination.** Is "is this harness us-nexus?" derived from the - authenticated principal/origin (server-side, trustworthy) or from `context.harness` - (caller-asserted, spoofable)? Must be server-derived to be load-bearing. -3. **Central relay: build or defer?** The distributed-library form is admissible without a ledger - entry. The fallback relay needs the I2 acceptance entry. Decide whether the relay ships in v1 or - whether Crush/Pi/headless are deferred until it's ledgered. -4. **Audit-sink-down behaviour.** Spec above says "refuse if unauditable." Confirm that's the - desired posture vs. degrade-and-warn — it's a real availability/assurance trade. +These were open questions at draft; resolved in the 2026-06-22 review session. Recorded here as +binding design decisions for the build. + +1. **Classification trust — model (C): caller-declares + server-cross-checks, stricter wins.** + The caller declares `context.classification`; the server **independently derives** the target's + classification (from the target wing/repo's classification tag) and gates on the **stricter of + the two**. The caller can voluntarily *raise* sensitivity but can never *lower* it below the + target's floor. A declared-vs-derived **mismatch is logged as a security event** (I5). + - **Prerequisite (new build work):** a classification taxonomy (e.g. `public` / + `internal` / `confidential`) and a per-wing / per-repo classification tag the server can read. + This must exist before the I1 gate is load-bearing. Tracked as a sub-task of #49. + - Rationale: composes with decision 2; fails safe; honours a caller flagging something *more* + sensitive than its destination. Pure caller-trust (A) was rejected — it makes the gate theatre. + +2. **Sovereign-harness determination — server-derived, not caller-asserted.** + "Is this harness us-nexus / sovereign?" is derived from the **authenticated principal/origin** + (the OAuth2 identity), never from `context.harness`. `context.harness` survives only as a + self-reported label for the audit log — descriptive telemetry, **never a gate input**. A control + keyed on an attacker-suppliable value is not a control. + +3. **Central relay — ships in v1, with the I2 ledger entry.** + The relay is required, not optional: claude.ai (Chat/Cowork/Design), Crush, Pi, and LLM Council + cannot run the use-case library in-process, and those are primary day-to-day surfaces. Deferring + the relay would ship a capability that doesn't work from the interfaces actually in use. Because + the relay is a (thin, no-standing-visibility, audit-only-retention) central node, its cross-harness + reach **must be entered in `infra/docs/security-baseline.md`** with Why-accepted / Revisit-if + **before it ships** (I2). That ledger entry is v1 work, not a follow-up. + +4. **Audit-sink-down — degrade-and-warn, with a durable local buffer + reconcile-on-recovery.** + If the central audit sink (loki) is unreachable, capture **proceeds** rather than blocking + (availability), BUT the audit record is written to a **durable local fallback buffer** and an + **ntfy alert** fires. Buffered records **reconcile to the central sink on recovery**; the local + buffer clears only after confirmed central write. **Floor:** if *neither* the central sink nor the + local buffer can be written, capture **refuses** — degrade-and-warn never means *un*-audited. + - Rationale: keeps capture available during an observability outage while preserving I5 — the + write is always recorded *somewhere durable*, just not centrally until reconciliation. This is + the difference between an I5 violation and an I5-compliant degraded mode, and it must be + presentable as such to a due-diligence client.