/ docs / plans / radicle_integration.md
radicle_integration.md
  1  # Auths-Radicle Integration Plan
  2  
  3  ## 1. Problem Statement
  4  
  5  Verification logic alone -- even if perfectly implemented and deployed as WASM to every node -- cannot solve revocation propagation in a P2P network. A verifier can only check facts it knows about: if a node has never received a revocation attestation, its verifier will correctly validate a signature from the revoked device because, from that node's perspective, the device is still authorized. The problem is not verification correctness but *state availability*. Revocation is an *event* that must physically propagate through the gossip network, and until it arrives, every honest node with stale state will make a locally-correct but globally-wrong authorization decision. Therefore, the integration must define explicit rules for what a node should do when its identity state may be incomplete, rather than relying on "verification works, so we're safe."
  6  
  7  ---
  8  
  9  ## 2. Source of Truth and Storage Model
 10  
 11  ### Chosen Model: Dedicated identity repository with project-level namespace binding (per RIP-X)
 12  
 13  RIP-X specifies that a KERI identity lives in its own Radicle repository, and projects reference it via a DID namespace under `refs/namespaces/did-keri-<prefix>/`. This plan follows that design exactly.
 14  
 15  **Where identity events live:** Each Auths identity (the KERI event log and device attestations) lives in a **dedicated identity repository** replicated via Radicle, one per identity. The repository layout follows RIP-X:
 16  
 17  ```
 18  <rid>                           # KERI identity repository
 19  └─ refs
 20     ├─ keri
 21     │  └─ kel                    # KEL commit history (tip = latest event)
 22     └─ keys
 23        ├─ <nid>                  # 2-way attestation for device A
 24        │  └─ signatures
 25        │     ├─ did-key          # Device's signature blob
 26        │     └─ did-keri         # Identity's signature blob
 27        └─ <nid>                  # 2-way attestation for device B
 28           └─ signatures
 29              ├─ did-key
 30              └─ did-keri
 31  ```
 32  
 33  **Where project-level binding lives:** Per RIP-X, each project repository gains a new namespace entry for each KERI identity that participates:
 34  
 35  ```
 36  <project-rid>
 37  └─ refs
 38     └─ namespaces
 39        ├─ did-keri-EXq5...      # Identity namespace (new, per RIP-X)
 40        │  └─ refs
 41        │     ├─ rad
 42        │     │  └─ id            # Blob pointing to the identity repo RID
 43        │     └─ heads/...        # Canonical refs for this identity
 44        ├─ <nid-A>               # Device A's fork (existing pattern)
 45        │  └─ refs
 46        └─ <nid-B>               # Device B's fork (existing pattern)
 47           └─ refs
 48  ```
 49  
 50  The `refs/namespaces/did-keri-<prefix>/refs/rad/id` blob contains the RID of the identity repository. This is how a node discovers which identity repo to fetch.
 51  
 52  ### Why this model
 53  
 54  | Criterion | RIP-X model (chosen) | Alternative: embed in each project |
 55  |---|---|---|
 56  | **Revocation correctness** | Single authoritative source; revocations propagate once via identity repo | Revocation must be pushed to every project repo independently; easy to miss one |
 57  | **Minimal codebase change** | Heartwood already has `IdentityNamespace` parsing for `did-keri-` prefixes; identity repos are just normal Radicle repos | Requires modifying every project's identity doc schema and replication logic |
 58  | **Operational simplicity** | Identity owner manages one repo; projects just reference it via namespace | Every project must be updated on revocation |
 59  | **RIP-X compliance** | Direct match | Deviates from the RIP |
 60  
 61  The key insight from fn-4 (Heartwood's fetch/protocol epic): when a node fetches a project and encounters a `refs/namespaces/did-keri-<prefix>/refs/rad/id` blob, it queues a best-effort fetch of the referenced identity repo. This creates automatic replication dependency -- nodes that seed a project will discover and seed the identity repo without manual intervention.
 62  
 63  ---
 64  
 65  ## 3. Roles and Responsibilities
 66  
 67  ### auths crates (auths-core, auths-id, auths-verifier)
 68  
 69  **Owns:**
 70  - Identity truth: creation, storage, and validation of KERI event logs (inception, rotation, interaction events)
 71  - Attestation lifecycle: creation, dual-signing (per RIP-X: `keri.sign(RID, did:key)` + `key.sign(RID, did:keri)`), revocation
 72  - Policy evaluation: capability checks, expiration checks, revocation checks against *locally available* state
 73  - DID resolution: converting `did:keri:` and `did:key:` to public key material
 74  - Key state computation: `validate_kel()` produces `KeyState` from an ordered event sequence
 75  
 76  **Does not own:** P2P replication, network transport, peer discovery, or any concept of "freshness" relative to the network.
 77  
 78  ### heartwood crates (radicle, radicle-fetch, radicle-node, radicle-cob)
 79  
 80  **Owns:**
 81  - P2P replication: gossip protocol, `RefsAnnouncement`, fetch pipeline
 82  - Cryptographic signature verification: Ed25519 verification of `SignedRefs` and COB commits
 83  - Project identity documents: `Doc` with delegates (including `Did::Keri` once fn-1 lands), threshold, visibility, payloads
 84  - Repository storage: namespaced refs (including `did-keri-` identity namespaces per RIP-X), signed refs branches, Git object storage
 85  - Namespace classification: `NamespaceKind::Peer(NodeId)` vs `NamespaceKind::Identity(Did)` (fn-4)
 86  - Seeding policy: which repos to replicate, which peers to follow
 87  - Identity repo auto-fetch: when encountering a DID namespace, queue fetch of the referenced identity repo (fn-4, best-effort)
 88  
 89  **Does not own:** KERI event validation, capability-based authorization, or revocation semantics beyond its existing delegate threshold model.
 90  
 91  ### auths-radicle (the bridge)
 92  
 93  **Responsible for:**
 94  
 95  1. **DID translation:** Converting Radicle's `[u8; 32]` Ed25519 keys to Auths `did:key:z...` format and back. This is the type-system boundary.
 96  
 97  2. **Ref path constants:** Exporting RIP-X ref paths (`refs/keri/kel`, `refs/keys/<nid>/signatures`) so both codebases use identical strings.
 98  
 99  3. **Identity state loading:** Reading the KERI event log and device attestations from the identity repository (accessed as a local Git repo that Radicle has replicated). Computing current `KeyState` via `validate_kel()`.
100  
101  4. **Authorization evaluation:** For a given signer key, answering: "Is this device currently authorized to perform this action on this project?" This involves:
102     - Finding the device's attestation under `refs/keys/<nid>/signatures`
103     - Verifying both signatures (RIP-X 2-way attestation)
104     - Checking revocation status
105     - Checking expiration
106     - Checking capability (e.g., `sign_commit`)
107     - Evaluating against the current `KeyState`
108  
109  5. **Staleness detection:** Determining whether the locally-available identity state may be stale. The bridge compares what it has locally against what the gossip layer knows is available (see Section 4).
110  
111  6. **Result production:** Returning a `VerifyResult` (Verified / Rejected / Quarantine) that Radicle's fetch pipeline can act on. The bridge never modifies repository state; it only reads and decides.
112  
113  **Refuses to do:**
114  - Sign anything (zero new crypto)
115  - Modify Radicle repository state directly
116  - Make network requests (it reads locally-replicated Git data only)
117  - Override Radicle's own signature verification (Radicle checks Ed25519; the bridge checks authorization)
118  
119  ---
120  
121  ## 4. Stale-State Policy: Compare and Choose
122  
123  ### Policy A: Eventual Consistency (fail-open)
124  
125  **Description:** Each node decides based solely on its local identity state. If a node hasn't received a revocation, it accepts the update. Disagreements resolve as revocations propagate.
126  
127  **User experience:** Seamless in the happy path. Users never see delays or rejections due to missing state. However, a revoked device can continue pushing updates to nodes that haven't received the revocation until gossip catches up.
128  
129  **Failure modes:**
130  - A revoked device's updates are accepted by stale nodes and become part of their local view. When the revocation arrives, those updates are already integrated. Cleaning them up requires manual intervention.
131  - In adversarial scenarios, a compromised device could race the revocation, pushing malicious updates to as many nodes as possible before the revocation propagates.
132  - An attacker can target nodes that haven't seeded identity repos, or exploit nodes with temporarily unreadable identity state (disk corruption, missing refs), and get accepted "because fallback."
133  
134  **Node A vs Node B scenario:** Node A (has revocation) rejects the update. Node B (stale) accepts it. When Node B eventually receives the revocation, it has already accepted the update. There is no automatic rollback. The accepted update persists unless a delegate explicitly reverts it.
135  
136  **Complexity:** Lowest. No additional state tracking. No quarantine mechanism. But cleanup after revocation races is undefined, and the system cannot honestly claim to "prevent unauthorized pushes" -- it only detects them after the fact.
137  
138  ### Policy B: Freshness Requirement (fail-closed on staleness)
139  
140  **Description:** Nodes refuse to make authorization decisions unless their identity state is "fresh enough." Freshness is defined as: the node has synced the identity repository within a configurable window, OR there are no pending identity repo updates in the gossip layer.
141  
142  **User experience:** Nodes that are offline or poorly connected may reject valid updates because they can't confirm freshness. This creates friction in truly offline or intermittently-connected environments -- exactly the environments Radicle is designed for.
143  
144  **Failure modes:**
145  - Offline nodes become unable to accept any updates, even from non-revoked devices.
146  - Creates a soft availability dependency on the identity repository being reachable, undermining Radicle's offline-first design.
147  - Clock-based freshness is unreliable in P2P networks with no shared clock.
148  
149  **Node A vs Node B scenario:** Node A (has revocation) rejects. Node B checks freshness: if it hasn't synced recently, it quarantines. This is safe but blocks legitimate work when nodes are offline.
150  
151  **Complexity:** Medium-high. Requires freshness tracking, configurable windows, and graceful degradation. Adds a soft liveness dependency.
152  
153  ### Policy C: Proof-Carrying Authorization (fail-closed on missing proof)
154  
155  **Description:** Every signed update carries enough evidence for any node to verify authorization *without* consulting external identity state. Specifically, each update includes: the device's current attestation, and a KEL snapshot up to the event that anchors that attestation. A node verifies the proof bundle independently.
156  
157  **User experience:** Updates are self-contained. Nodes can verify authorization even while fully offline. No freshness anxiety and no quarantine friction.
158  
159  **Failure modes:**
160  - Proof size: carrying attestations + KEL snapshots with every update adds overhead.
161  - Revocation latency: a revoked device can continue producing valid-looking proofs until the revocation is anchored in the KEL. The window is bounded by the time between compromise and revocation anchoring.
162  - Complexity of proof construction: the signing client must assemble proof bundles.
163  - **Protocol impact:** Proof bundles must be stored as auxiliary Git objects alongside the update, not embedded in `SignedRefs` or the wire format (see below for why).
164  
165  **Node A vs Node B scenario:** Node B receives the update along with the proof bundle. If the proof was assembled before the revocation was anchored, it's valid and Node B accepts. Once the revocation is anchored, new proof bundles from that device fail. The vulnerability window equals the time between compromise and KEL update.
166  
167  **Complexity:** Highest. But provides the strongest offline guarantee.
168  
169  ### Decision
170  
171  **MVP: Policy A (Eventual Consistency) for observe mode + Policy B-lite (quarantine-on-known-staleness) for enforce mode**
172  
173  Projects that opt into Auths identity choose an enforcement level:
174  
175  #### Observe mode (default for MVP)
176  
177  The bridge checks identity state if available and produces `Verified` / `Rejected` / `Warn` results, but the node always applies the update regardless. Results are logged and surfaced in `Validations`. This is a detection-and-flagging system, not an authorization boundary.
178  
179  The system **does not claim to prevent unauthorized pushes** in observe mode. It detects and flags them with eventual convergence.
180  
181  #### Enforce mode (recommended for projects that need revocation guarantees)
182  
183  When a project opts into enforce mode, the bridge is a hard authorization boundary:
184  
185  - `Verified` -> apply the update
186  - `Rejected` -> reject the update (add validation error, prune the remote)
187  - **Identity state unavailable** (identity repo not seeded, not yet fetched, or unreadable) -> **quarantine**: reject the update with a specific `Quarantine` validation error indicating which identity repo RID is needed. The node can retry after fetching the identity repo.
188  
189  This is fail-closed for projects that opt in. The bridge does not "fall back to signature-only" for enforce-mode projects -- that would undermine the entire security model. If a project declares an Auths binding in enforce mode and the identity repo is missing, updates from KERI-attested devices are rejected until the identity state is available.
190  
191  **Backward compatibility:** Projects without any Auths binding are completely unaffected (no bridge call). Projects in observe mode are never blocked. Only enforce mode adds rejection, and it's opt-in per project.
192  
193  #### Enforce mode DoS resistance
194  
195  Enforce mode introduces a new DoS surface: if "identity repo missing/unreadable/behind => quarantine," an attacker can try to keep nodes perpetually quarantined by:
196  
197  - **Preventing identity repo fetch** (network partition, peer selection attacks): The mitigation is that quarantine is *per-update*, not per-project. The node continues to accept updates from `Did::Key` delegates (which don't require the bridge) and from devices whose identity state *is* locally available and current. Only the specific updates that can't be authorized are quarantined. The node is never fully blocked from a project.
198  - **Constantly advancing the identity repo** to keep nodes "behind": This requires the attacker to be a delegate of the identity repo (only delegates can push signed refs). If a delegate is malicious, the identity is already compromised -- this is not a new attack surface. A non-delegate cannot advance the identity repo's signed refs.
199  - **Corrupting local identity repo storage**: If the identity repo on disk becomes unreadable, the bridge returns `Quarantine` for enforce mode. This is correct behavior -- "I can't read the identity state" should not mean "accept everything." The node operator resolves this by re-fetching the identity repo (`rad sync --fetch`). This is recoverable, not permanent.
200  
201  **Quarantine timeout**: If a node has been quarantining updates for a specific identity for more than a configurable period (default: 24 hours) without being able to resolve the quarantine (e.g., identity repo is persistently unreachable), the node logs a critical warning. It does *not* auto-downgrade to observe mode -- that would defeat the purpose. The operator must explicitly intervene: either fix the fetch problem, downgrade to observe mode, or remove the KERI delegate from the project. This is an operational decision, not an automatic one.
202  
203  ### Staleness detection (replaces min_kel_seq-as-freshness)
204  
205  The previous draft used `min_kel_seq` from the project binding as a freshness heuristic. This does not actually detect the "Node B accepts revoked device" case -- a node can be at sequence 3 (above the binding minimum of 2) while the revocation is at sequence 4.
206  
207  `min_kel_seq` serves a different purpose: **binding integrity**. It prevents a node from accepting identity state that predates the binding itself (e.g., an attacker feeding a truncated KEL). It is not a freshness tool.
208  
209  For actual staleness detection, the bridge uses a **gossip-informed heuristic**:
210  
211  > **Warn when the node knows there is a newer identity repo tip available (via `RefsAnnouncement`) but hasn't fetched it yet.**
212  
213  This is concretely actionable:
214  - Radicle's gossip layer already announces `RefsAt(remote, oid)` for every repo. The node can compare its local identity repo tip against the announced tip.
215  - If they differ, the bridge returns `Warn` (observe mode) or `Quarantine` (enforce mode) with metadata: "identity repo `rad:z3gq...` has a newer tip `abc123` available -- fetch it."
216  - No shared clocks needed. No configurable windows. Just: "I know I'm behind."
217  - If no announcement has been received (fully disconnected), the node has no reason to suspect staleness and proceeds based on local state.
218  
219  #### Which peers can trigger staleness signals
220  
221  Not all `RefsAnnouncement` sources are equally trustworthy. An attacker could spam false "newer tip" announcements to force quarantine on enforce-mode nodes (a DoS vector). The policy:
222  
223  1. **Only announcements from peers that are delegates of the identity repo (or tracked/followed peers for that repo) are considered credible.** Radicle already tracks which peers are delegates via the identity repo's own `Doc.delegates`. An announcement from a random peer is ignored for staleness purposes.
224  2. **A single credible announcement is sufficient to trigger a staleness signal.** We do not require multiple independent announcements. Rationale: the identity repo has a small, known delegate set (typically 1-3). Requiring a quorum would add complexity without meaningful security gain -- if an identity delegate is compromised, the identity itself has bigger problems than false staleness signals.
225  3. **An attacker who is not a delegate of the identity repo cannot trigger quarantine.** They can announce whatever they want, but the bridge filters by delegate status before acting on the signal.
226  
227  This means the staleness signal is only as trustworthy as the identity repo's delegate set -- which is the same trust root as the identity itself.
228  
229  ### Later hardening path (backward-compatible)
230  
231  **Phase 2: Proof-carrying authorization (Policy C) as additive auxiliary Git objects.**
232  
233  Proof bundles are stored as **additional Git blobs in the identity repo** (not in `SignedRefs` or the wire format). A ref like `refs/keys/<nid>/proof-bundle` contains the attestation + KEL snapshot. Nodes that understand proof bundles verify them; nodes that don't (older versions) ignore the extra refs and fall back to direct identity repo lookup.
234  
235  This avoids modifying `SignedRefs` canonicalization or the gossip wire format. `SignedRefs` uses a text-canonical format (`<oid> <ref>\n` lines) that is safe to extend with new refs but should not have its structure changed. The gossip `RefsAnnouncement` is binary-encoded and carries only `(rid, [(remote, oid)])` -- adding fields would require a protocol version bump. Proof bundles as auxiliary Git objects require neither.
236  
237  ---
238  
239  ## 5. Concrete Propagation Flow
240  
241  ### 5A. Normal Operation: Device Authorization and Update Acceptance
242  
243  **Step 1: Identity creation.**
244  Alice creates a KERI identity. Per RIP-X, this generates:
245  - A KERI inception event with her initial Ed25519 key pair
246  - A `did:keri:<prefix>` identity DID derived from the SAID (Blake3 hash) of the inception event
247  - The KEL is stored as a commit chain under `refs/keri/kel`
248  
249  **Step 2: Identity repository publication.**
250  Alice publishes her identity as a Radicle repository using `rad identity init-keri` (fn-5 CLI). This creates a Radicle `RepoId` (RID) for her identity and seeds it to the network.
251  
252  **Step 3: Device authorization.**
253  Alice has a second device (laptop). Per RIP-X, she creates a 2-way attestation:
254  - `keri.sign(RID, did:key)` -- identity key signs the binding
255  - `key.sign(RID, did:keri)` -- device key signs the binding
256  - Both signatures are stored as blobs under `refs/keys/<laptop-nid>/signatures/` in the identity repo
257  - She pushes the updated identity repo: `git push rad`
258  
259  **Step 4: Project setup with identity namespace.**
260  Alice creates a Radicle project. Per RIP-X, a new namespace is created:
261  - `refs/namespaces/did-keri-<prefix>/refs/rad/id` -> blob containing the identity repo RID
262  - `refs/namespaces/did-keri-<prefix>/refs/heads/...` -> canonical refs for the identity
263  - The project's `Doc` delegates include `Did::Keri(prefix)` alongside any `Did::Key` delegates
264  
265  **Step 5: Signed update from authorized device.**
266  Alice's laptop pushes a commit to the project. Radicle creates `SignedRefs` signed by the laptop's Ed25519 key. The update appears under `refs/namespaces/<laptop-nid>/...`.
267  
268  **Step 6: Node receives update.**
269  Bob's node receives Alice's `RefsAnnouncement`. During the fetch pipeline:
270  
271  1. Radicle verifies the Ed25519 signature on `SignedRefs` (existing logic, unchanged)
272  2. Radicle classifies the remote's namespace. The laptop's `<nid>` is a `Peer` namespace.
273  3. The fetch pipeline encounters `refs/namespaces/did-keri-<prefix>/refs/rad/id` and queues a fetch of Alice's identity repo (fn-4 design, best-effort)
274  4. Per fn-3, `SignedRefs::verify_with_identity()` is called. It tries the fast path (exact delegate key match) first. Since the laptop isn't a direct delegate, it falls back to the auths-radicle bridge:
275     a. Bridge reads Alice's identity repo from Bob's local storage
276     b. Bridge loads the KEL and computes `KeyState` via `validate_kel()`
277     c. Bridge converts the laptop's `[u8; 32]` key to `did:key:z6Mk...`
278     d. Bridge loads the 2-way attestation from `refs/keys/<laptop-nid>/signatures`
279     e. Bridge verifies both signatures, checks: not revoked, not expired, has `sign_commit` capability
280     f. Bridge returns `VerifyResult::Verified`
281  5. Radicle proceeds with ref validation and applies the update
282  
283  ### 5B. Revocation
284  
285  **Step 1: Revocation creation.**
286  Alice discovers her laptop was compromised. From her primary device, she runs `rad identity device revoke <laptop-nid>` (fn-5). This:
287  - Sets `revoked_at` on the attestation and re-signs with the KERI identity key
288  - Anchors the revocation in the KEL via an interaction event (IXN)
289  - KEL sequence advances (e.g., from 2 to 3)
290  
291  **Step 2: Publication.**
292  Alice pushes the updated identity repository to Radicle. The identity repo's `SignedRefs` update, and a `RefsAnnouncement` propagates through the gossip network.
293  
294  **Step 3: Network propagation.**
295  Nodes that seed Alice's identity repository receive the `RefsAnnouncement`, fetch the updated KEL and revocation. Their local state now reflects the revocation.
296  
297  **Step 4: Post-revocation verification (node has revocation).**
298  When any node with the updated identity state receives an update signed by the revoked laptop key:
299  1. Radicle verifies the Ed25519 signature (passes -- the signature is cryptographically valid)
300  2. The bridge is consulted:
301     a. Bridge loads the attestation for the laptop's DID
302     b. Bridge finds `revoked_at` is set
303     c. Bridge returns `VerifyResult::Rejected { reason: "Device <laptop-nid> was revoked at 2026-03-01T12:00:00Z" }`
304  3. Radicle rejects the ref update (enforce mode) or records a validation warning (observe mode)
305  
306  ### 5C. The Stale-Node Scenario
307  
308  **Setup:**
309  - Alice revoked her laptop at KEL sequence 3
310  - Node A (Carol) has synced Alice's identity repo and has KEL up to sequence 3 (includes revocation)
311  - Node B (Dave) has not synced Alice's identity repo recently; his local copy is at KEL sequence 2 (no revocation)
312  - The compromised laptop pushes an update to the project, and Dave's node receives it
313  
314  **What happens at Dave's node (stale state):**
315  
316  1. **Signature verification passes.** The Ed25519 signature is valid.
317  
318  2. **Bridge is consulted.**
319     a. Bridge locates Alice's identity repo in Dave's local storage
320     b. Bridge loads the KEL: sequence 2 (no revocation event present)
321     c. Bridge loads attestation for the laptop: not revoked (in Dave's view), not expired, has capabilities
322  
323  3. **Gossip-informed staleness check:**
324     - If Dave's node has received a `RefsAnnouncement` for Alice's identity repo with a newer tip OID than what Dave has locally, the bridge detects staleness.
325     - If no such announcement has been received (Dave is fully disconnected), Dave has no reason to suspect staleness.
326  
327  4. **Outcome depends on mode:**
328  
329     **Observe mode:** Bridge returns `VerifyResult::Verified` (or `Warn` if staleness detected). Dave accepts the update. When Dave eventually syncs Alice's identity repo, he gets the revocation. From that point forward, the laptop is rejected. The previously-accepted update remains -- Dave's node cannot automatically roll it back.
330  
331     **Enforce mode with staleness detected:** Bridge returns `VerifyResult::Quarantine { reason: "Identity repo has newer tip available; fetch before deciding" }`. Dave's node rejects the update. When Dave syncs the identity repo and gets the revocation, the laptop would be rejected anyway. If the laptop had *not* been revoked, re-fetching the identity repo would resolve the quarantine and Dave could accept on retry.
332  
333     **Enforce mode without staleness detected (fully disconnected):** Bridge returns `VerifyResult::Verified` based on local state. This is the irreducible risk of any eventually-consistent system -- Dave has no reason to suspect his state is wrong and no way to check. The window closes when connectivity resumes.
334  
335  **Blast radius of stale acceptance:**
336  
337  The damage from accepting an update from a revoked device depends on what the revoked device can do:
338  
339  - **If the revoked device is a project delegate (via `Did::Keri`):** It can update canonical refs, subject to threshold. A compromised delegate is a serious incident regardless of Auths -- the project must have a threshold > 1 to survive this. Auths adds revocation capability; it doesn't change the blast radius of a compromised delegate.
340  - **If the revoked device is a non-delegate contributor:** Its updates live in its own `refs/namespaces/<nid>/...` namespace. Radicle's delegate threshold mechanism means non-delegate refs don't affect the canonical project state *by default*. However, this containment has limits:
341    - **No auto-merge from non-delegate namespaces.** Radicle does not auto-merge patches or auto-build from non-delegate forks without explicit delegate action (merging a patch requires a delegate to check it out, review, and push to their own namespace). So a revoked non-delegate device cannot inject code into canonical state without a human delegate approving the merge.
342    - **UI visibility matters.** Non-delegate namespaces are visible in `rad patch`, `rad ls`, and other CLI output. A stale-accepted update from a revoked device will appear as a valid-looking patch or fork until the revocation propagates and the node flags it. Users could be misled into reviewing or merging it. **Mitigation:** When a previously-accepted update is retroactively flagged (identity repo syncs and reveals revocation), the node should surface a prominent warning: "Patch/fork from `<nid>` was accepted before device revocation was known. Treat contents as untrusted." This warning should appear in `rad patch show`, `rad inbox`, and any UX surface that displays the contributor's data.
343    - **Object-level contamination.** Even though refs are namespaced, Git objects are shared. A malicious commit from a revoked device could reference the same tree as legitimate commits, or be a parent of future legitimate commits if a delegate inadvertently merges before the revocation arrives. This is a Git-level property, not something the bridge can prevent -- it reinforces that the operator should treat any stale-accepted data as potentially harmful.
344  
345  - **In both cases:** Any accepted update from a revoked device must be treated as potentially harmful. The project owner must explicitly revert or repair after the revocation propagates. There is no automatic rollback. The bridge logs every `Verified` decision with enough context (device DID, identity DID, KEL sequence at decision time) to support a post-hoc audit: "which updates were accepted from device X before its revocation was known?"
346  
347  ### 5D. Revocation Race Window: Formal Bound
348  
349  The system prevents unauthorized pushes only under specific conditions. Here is the precise bound:
350  
351  **Guarantee:** A push signed by a revoked device is rejected by every node that satisfies *at least one* of:
352  
353  1. **The node has fetched the identity repo at or past the KEL sequence containing the revocation seal.** This is the primary path. The revocation is an IXN event in the KEL; once the node has it, the device is rejected.
354  
355  2. **The node has received a credible gossip signal (from an identity repo delegate) that a newer identity repo tip exists, AND the project is in enforce mode.** The push is quarantined until the node fetches the update. If the update contains the revocation, the push is rejected. If the update does *not* contain the revocation (the newer tip was for an unrelated event), the quarantine resolves and the push is accepted.
356  
357  **No guarantee:** A fully-disconnected node with no gossip signal operates on cached authority. It has no mechanism to learn about the revocation and will accept the push as locally valid. This is the irreducible risk of any system that operates offline.
358  
359  **Bound on the vulnerability window:**
360  
361  ```
362  T_vulnerable = T_propagation + T_fetch
363  
364  Where:
365    T_propagation = time for RefsAnnouncement to reach the node via gossip
366                    (typically seconds on a connected network; unbounded if disconnected)
367    T_fetch       = time for the node to fetch the updated identity repo after
368                    receiving the announcement (typically seconds; bounded by
369                    Git transfer speed)
370  ```
371  
372  For connected nodes on a well-seeded network, `T_vulnerable` is on the order of seconds to low minutes. For nodes with intermittent connectivity, it equals their disconnection interval. For permanently-offline nodes, it is infinite -- they run on cached authority indefinitely.
373  
374  **This is not a weakness unique to this design.** It is the same bound as every revocation system that operates without a synchronous online check (TLS CRL/OCSP, PGP key revocation, SSH certificate revocation). The contribution of this design is making the bound *explicit* and providing the enforce-mode quarantine to narrow it for nodes that do have gossip connectivity.
375  
376  ---
377  
378  ## 6. Minimal Hook Point in Radicle
379  
380  **Where:** Per fn-3's design, the hook is in `SignedRefs::verify_with_identity()` -- a new method that extends the existing `SignedRefs::verify()`. The existing method handles the fast path (exact delegate key match); the new method adds a fallback path for KERI-attested devices.
381  
382  Concretely, in the fetch pipeline (fn-4, `FetchState::run()`): after `SignedRefs<Unverified>` is loaded for a remote, and after the Ed25519 signature is verified, the pipeline checks whether the signer's `NodeId` is:
383  1. A direct delegate (`Did::Key` in `Doc.delegates`) -> accept via existing path
384  2. An attested device of a `Did::Keri` delegate -> consult the auths-radicle bridge
385  3. Neither -> reject (non-delegate, non-attested)
386  
387  **What the bridge call does:**
388  1. Read the identity repo RID from `refs/namespaces/did-keri-<prefix>/refs/rad/id`
389  2. Load the identity repo from local Radicle storage
390  3. Run authorization checks (KEL validation, attestation verification, revocation/expiry/capability checks)
391  4. Return `VerifyResult` mapped to Radicle's existing `Validations` / prune flow:
392     - `Verified` -> accept as a valid contributor under this KERI identity
393     - `Rejected` -> prune the remote, add validation error (enforce mode), or add warning (observe mode)
394     - `Quarantine` -> prune the remote, add validation error indicating which identity repo to fetch (enforce mode only)
395  
396  **What "reject" means precisely:**
397  
398  In Radicle, "accepting an update" involves multiple layers. The bridge operates at the **ref update** layer, not the transport layer. Specifically:
399  
400  1. **Git objects are always fetched and stored.** The Git packfile transfer happens before the bridge is consulted. Objects (commits, trees, blobs) are written to the local Git object store regardless of the bridge verdict. This is unavoidable -- the fetch protocol transfers objects before refs are validated. Blocking at the object level would require aborting the Git protocol mid-stream, which is fragile and leaks metadata anyway.
401  
402  2. **Ref updates are gated by the bridge.** After objects are stored, the fetch pipeline decides which refs to update. This is where the bridge's verdict takes effect:
403     - `Verified`: The remote's refs (under `refs/namespaces/<nid>/...`) are updated to point to the fetched objects.
404     - `Rejected` / `Quarantine`: The remote's ref updates are **pruned** -- the local refs are not advanced. The fetched objects become unreachable (no ref points to them) and will be garbage-collected by Git's normal GC.
405  
406  3. **Canonical refs are never updated for rejected remotes.** The canonical refs (under `refs/namespaces/did-keri-<prefix>/refs/heads/...`) aggregate only from verified device namespaces. A rejected device's objects never appear in the canonical view.
407  
408  4. **COBs (issues, patches) authored by rejected devices are not applied.** COB operations check the same authorization path. A rejected device's COB entries are pruned alongside its refs.
409  
410  This means: rejected updates leave transient Git objects on disk (until GC), but no refs, no canonical state, and no COB state are affected. The UI never sees them.
411  
412  **If the bridge is unavailable** (identity repo not in local storage, bridge returns error):
413  - **Observe mode:** Accept the update, log a warning. The bridge is informational only.
414  - **Enforce mode:** Reject the update. The project opted into hard authorization. "Unable to verify" is not "verified." This is fail-closed for projects that require it.
415  - **No Auths binding (no DID namespace in project):** The bridge is never called. Behavior is identical to current Heartwood. This is the default for all existing projects.
416  
417  **Feature flag:** The bridge integration is behind the fn-1 work (extending `Did` to an enum). Once `Did::Keri` exists in Heartwood's type system, the multi-device verification path is structurally available. There is no separate feature flag needed in `radicle-fetch` because the bridge call is gated on the presence of a `Did::Keri` delegate in the project's `Doc` -- no KERI delegate means no bridge call.
418  
419  ---
420  
421  ## 7. Execution Roadmap
422  
423  ### Relationship to Existing Heartwood Epics (fn-1 through fn-5)
424  
425  This plan's epics are designed to complement, not duplicate, the Heartwood `.flow/` epics. The mapping:
426  
427  | Heartwood epic | This plan's coverage |
428  |---|---|
429  | fn-1 (Core types: `Did` enum, `IdentityNamespace`) | Not duplicated. This plan assumes fn-1 is completed in Heartwood. |
430  | fn-2 (KERI storage: `KeriIdentityStore`, `GitKeriIdentityStore`) | Partially overlaps with our Epic 2 (identity state loading). Our Epic 2 focuses on the auths-radicle bridge side; fn-2 focuses on the Heartwood storage adapter side. |
431  | fn-3 (SignedRefs verification: `DeviceAuthorityChecker`) | Directly consumed by our Epic 3 (authorization checks). fn-3.2 explicitly wires `auths-radicle::DefaultBridge` as the backend. |
432  | fn-4 (Fetch/protocol: identity repo auto-fetch, namespace classification) | Directly consumed by our Epic 5 (Radicle integration seam). |
433  | fn-5 (CLI: `rad identity init-keri`, `device add/revoke/list`) | Not duplicated. This plan assumes fn-5 is completed in Heartwood. |
434  
435  The epics below focus on work in the **auths** repository (auths-radicle, auths-id, auths-verifier) and cross-repo integration testing.
436  
437  ---
438  
439  ### Epic 1: RIP-X Ref Layout and Attestation Format Alignment
440  
441  **Objective:** Align auths-radicle's ref paths and attestation format with RIP-X's specification.
442  
443  **Why it matters:** RIP-X defines specific ref paths (`refs/keri/kel`, `refs/keys/<nid>/signatures`) and a specific 2-way attestation format (`keri.sign(RID, did:key)` + `key.sign(RID, did:keri)`). The existing auths-id code uses different paths (`refs/did/keri/<prefix>/kel`, `refs/auths/devices/nodes/<did>`). The bridge must map between these or the existing auths-id code must support the RIP-X layout.
444  
445  **Success metrics:**
446  - Ref path constants for RIP-X layout exist in auths-radicle
447  - Attestation serialization/deserialization handles the RIP-X 2-blob format (separate `did-key` and `did-keri` signature blobs)
448  - Round-trip: create attestation in RIP-X format, store under RIP-X refs, read it back
449  
450  **Exit criteria:**
451  - Constants match RIP-X spec exactly
452  - Bridge can read attestations stored in RIP-X format
453  - 100% of format tests pass
454  
455  #### Tasks
456  
457  **Task 1.1: Define RIP-X ref path constants in auths-radicle**
458  - *Why:* Both codebases must agree on ref paths. auths-radicle is the source of truth for the mapping.
459  - *Acceptance criteria:* Constants for `KERI_KEL_REF` (`refs/keri/kel`), `KEYS_PREFIX` (`refs/keys`), `SIGNATURES_REF` (`signatures`), `DID_KEY_BLOB` (`did-key`), `DID_KERI_BLOB` (`did-keri`). Documented with their RIP-X section references.
460  - *Test plan:*
461    - Unit: constants match RIP-X spec strings exactly
462    - Unit: path construction helpers produce valid Git refnames
463  - *Affected areas:* `crates/auths-radicle/src/refs.rs` (new file, per fn-5.2)
464  
465  **Task 1.2: Attestation-to-bytes / from-bytes for RIP-X format**
466  - *Why:* RIP-X stores attestation signatures as two separate Git blobs, not as a single JSON attestation. auths-verifier needs to support this.
467  - *Acceptance criteria:* `Attestation::to_bytes()` / `from_bytes()` round-trip for the RIP-X 2-blob format. The canonical payload for signing is `(RID, other_did)` as specified in RIP-X.
468  - *Test plan:*
469    - Unit: serialize attestation to two blobs, deserialize back, signatures verify
470    - Unit: reject truncated/corrupt blobs
471    - Unit: reject mismatched RID (tamper detection)
472  - *Affected areas:* `crates/auths-verifier/src/` (per fn-5.3), `crates/auths-radicle/src/bridge.rs`
473  
474  **Task 1.3: `GitKel::with_ref()` constructor for custom ref paths**
475  - *Why:* auths-id's KEL reader currently uses `refs/did/keri/<prefix>/kel`. For RIP-X, it needs to read from `refs/keri/kel`.
476  - *Acceptance criteria:* `GitKel` accepts an optional custom ref path, defaulting to the existing path but allowing `refs/keri/kel` for RIP-X repositories.
477  - *Test plan:*
478    - Unit: default path works as before (no regression)
479    - Unit: custom path reads KEL from `refs/keri/kel`
480    - Unit: invalid ref path returns error
481  - *Affected areas:* `crates/auths-id/src/keri/` (per fn-6.1, fn-6.2, fn-6.3)
482  
483  ---
484  
485  ### Epic 2: Identity State Loading from Radicle-Replicated Repos
486  
487  **Objective:** Enable the bridge to read KERI identity state from locally-replicated Radicle repositories.
488  
489  **Why it matters:** The bridge makes authorization decisions based on identity state. It must be able to open a Radicle-replicated identity repo (by RID), load the KEL, compute `KeyState`, and load device attestations -- all from local Git storage.
490  
491  **Success metrics:**
492  - `AuthsStorage` impl can load `KeyState` and attestations from a Radicle-replicated repo
493  - Handles missing repos, corrupt KELs, and missing attestations with domain-specific errors
494  - Reports KEL sequence for staleness comparison
495  
496  **Exit criteria:**
497  - Bridge loads `KeyState` from a test identity repo in RIP-X layout
498  - All error scenarios return appropriate `BridgeError` variants
499  - Integration test: create identity repo, replicate it (simulate by copying), load via bridge
500  
501  #### Tasks
502  
503  **Task 2.1: `AuthsStorage` implementation for Radicle-backed repos**
504  - *Why:* The existing `AuthsStorage` trait in auths-radicle needs a concrete impl that reads from Radicle's Git storage
505  - *Acceptance criteria:* Given a local filesystem path to a Radicle-stored identity repo, loads: (a) KEL events from `refs/keri/kel` commit chain, (b) device attestations from `refs/keys/<nid>/signatures`, (c) computes `KeyState` via `validate_kel()`. Returns `BridgeError` variants for all failure modes.
506  - *Test plan:*
507    - Unit: load valid KEL, verify `KeyState` fields match expected
508    - Unit: load 2-way attestation for known device NID
509    - Unit: missing identity repo -> `BridgeError::IdentityLoad`
510    - Unit: corrupt/truncated KEL -> `BridgeError::PolicyEvaluation` (wrapping `ValidationError`)
511    - Unit: missing attestation for unknown NID -> `BridgeError::AttestationLoad`
512    - Integration: create full identity repo with inception + attestation, load via bridge, verify
513  - *Affected areas:* `crates/auths-radicle/src/verify.rs`
514  
515  **Task 2.2: `find_identity_for_device()` implementation**
516  - *Why:* The bridge needs to discover which KERI identity (if any) a given NodeId is attested under. Per fn-5.1, this method is added to the `RadicleAuthsBridge` trait.
517  - *Acceptance criteria:* Given a device's NodeId and a project repository, scan `refs/namespaces/did-keri-*/refs/rad/id` to find identity repos, then check each identity repo's `refs/keys/<nid>` for a matching attestation. Returns the KERI DID or `None`.
518  - *Test plan:*
519    - Unit: device attested under one identity -> returns that identity's DID
520    - Unit: device not attested under any identity -> returns `None`
521    - Unit: device attested under multiple identities (edge case) -> returns first match with warning
522    - Unit: identity repo not locally available -> returns `None` (not error)
523  - *Affected areas:* `crates/auths-radicle/src/bridge.rs` (per fn-5.1)
524  
525  ---
526  
527  ### Epic 3: Authorization Checks in the Bridge
528  
529  **Objective:** Implement the full authorization evaluation pipeline.
530  
531  **Why it matters:** This is the core value: answering "is this device authorized?" using Auths identity state, per fn-3's `DeviceAuthorization` design.
532  
533  **Success metrics:**
534  - Bridge correctly authorizes valid devices
535  - Bridge correctly rejects revoked, expired, and unauthorized devices
536  - Bridge correctly checks capabilities
537  - Works with fn-3.2's `CompositeAuthorityChecker` integration
538  
539  **Exit criteria:**
540  - All authorization scenarios produce correct `VerifyResult`
541  - 100% of authorization tests pass including edge cases and tamper scenarios
542  
543  #### Tasks
544  
545  **Task 3.1: Full verification pipeline (wired to Radicle-backed storage)**
546  - *Why:* `DefaultBridge::verify_signer()` must work against the `AuthsStorage` impl from Epic 2
547  - *Acceptance criteria:* `verify_signer()` executes: DID translation -> identity repo lookup -> KEL validation -> attestation load -> RIP-X 2-way signature verification -> policy evaluation -> result.
548  - *Test plan:*
549    - Unit: valid device with `sign_commit` capability -> `Verified`
550    - Unit: revoked device -> `Rejected`
551    - Unit: expired attestation -> `Rejected`
552    - Unit: device with wrong capability -> `Rejected`
553    - Unit: unknown device (no attestation) -> `Rejected`
554    - Unit: valid device after key rotation (KEL sequence > 0) -> `Verified`
555    - Tamper: modified attestation blob (signature mismatch) -> `Rejected`
556    - Tamper: swapped `did-key` and `did-keri` blobs -> `Rejected`
557  - *Affected areas:* `crates/auths-radicle/src/verify.rs`, `crates/auths-radicle/src/bridge.rs`
558  
559  **Task 3.2: Capability-scoped authorization**
560  - *Why:* Different operations require different capabilities
561  - *Acceptance criteria:* Bridge accepts a required capability and checks it against the attestation. `sign_commit` for ref updates; `sign_release` for release tags.
562  - *Test plan:*
563    - Unit: device with `sign_commit` pushing refs -> `Verified`
564    - Unit: device with only `sign_release` pushing refs -> `Rejected`
565    - Unit: device with `[sign_commit, sign_release]` pushing release tag -> `Verified`
566  - *Affected areas:* `crates/auths-radicle/src/bridge.rs`, `crates/auths-radicle/src/verify.rs`
567  
568  **Task 3.3: Threshold verification for multi-delegate projects**
569  - *Why:* Radicle projects can require M-of-N delegates
570  - *Acceptance criteria:* `verify_multiple_signers()` and `meets_threshold()` work with the full pipeline
571  - *Test plan:*
572    - Unit: 3 signers, threshold 2, all valid -> passes
573    - Unit: 3 signers, threshold 2, one revoked -> passes (2 valid remain)
574    - Unit: 3 signers, threshold 2, two revoked -> fails
575    - Unit: mixed `Did::Key` + `Did::Keri` delegates in threshold -> both types checked correctly
576  - *Affected areas:* `crates/auths-radicle/src/verify.rs`
577  
578  ---
579  
580  ### Epic 4: Stale-State Handling and Enforcement Modes
581  
582  **Objective:** Implement observe/enforce modes and gossip-informed staleness detection.
583  
584  **Why it matters:** Without explicit staleness handling, the system silently makes wrong decisions when identity state is incomplete. Without enforcement modes, users cannot choose their security/availability tradeoff.
585  
586  **Success metrics:**
587  - Observe mode: never blocks updates, always logs warnings
588  - Enforce mode: rejects updates when identity state is unavailable or known-stale
589  - Gossip-informed staleness: warns when local identity repo tip differs from announced tip
590  - `min_kel_seq` correctly enforces binding integrity (not freshness)
591  
592  **Exit criteria:**
593  - All mode/staleness combinations tested
594  - Stale-state test matrix passes
595  - Warning and quarantine messages include actionable information
596  
597  #### Tasks
598  
599  **Task 4.1: Enforcement mode configuration**
600  - *Why:* Projects must choose their security/availability tradeoff
601  - *Acceptance criteria:* Bridge accepts an enforcement mode (observe/enforce) per verification call. In observe mode, `Rejected` results are downgraded to `Warn`. In enforce mode, `Rejected` and `Quarantine` are hard rejections.
602  - *Test plan:*
603    - Unit: observe mode + revoked device -> `Warn` (not `Rejected`)
604    - Unit: enforce mode + revoked device -> `Rejected`
605    - Unit: observe mode + missing identity repo -> `Warn`
606    - Unit: enforce mode + missing identity repo -> `Quarantine`
607  - *Affected areas:* `crates/auths-radicle/src/bridge.rs`, `crates/auths-radicle/src/verify.rs`
608  
609  **Task 4.2: Gossip-informed staleness detection**
610  - *Why:* `min_kel_seq` doesn't detect the "Node B at seq 3, revocation at seq 4" case. We need a better signal.
611  - *Acceptance criteria:* Bridge accepts an optional `known_remote_tip: Option<Oid>` (the latest identity repo tip OID seen via gossip). If provided and differs from the local identity repo tip, the bridge returns a staleness warning (`Warn` in observe, `Quarantine` in enforce).
612  - *Test plan:*
613    - Unit: local tip == remote tip -> no staleness warning
614    - Unit: local tip != remote tip -> staleness detected
615    - Unit: no remote tip known (disconnected) -> no staleness warning (can't know)
616    - Unit: remote tip provided, identity repo missing locally -> staleness + missing state
617  - *Affected areas:* `crates/auths-radicle/src/verify.rs`
618  
619  **Task 4.3: Binding integrity via `min_kel_seq`**
620  - *Why:* Prevents accepting identity state that predates the project binding (e.g., attacker feeding truncated KEL)
621  - *Acceptance criteria:* Bridge compares `local_kel_sequence` against `min_kel_seq` from the project binding. If local < minimum, the result is `Rejected` (not just `Warn`) -- this is a tamper indicator, not a freshness signal.
622  - *Test plan:*
623    - Unit: local seq 5, min seq 2 -> passes
624    - Unit: local seq 2, min seq 2 -> passes (at minimum)
625    - Unit: local seq 1, min seq 2 -> `Rejected` (binding integrity violation)
626    - Unit: local seq 0 (only inception), min seq 3 -> `Rejected`
627  - *Affected areas:* `crates/auths-radicle/src/verify.rs`
628  
629  **Task 4.4: Stale-state integration tests**
630  - *Why:* Must prove the system behaves correctly in the Node A vs Node B scenario under both modes
631  - *Acceptance criteria:* Integration tests simulating two nodes with different identity state, verifying correct behavior per mode
632  - *Test plan:*
633    - Integration (observe): stale node accepts revoked device's update with `Warn`; after sync, rejects
634    - Integration (enforce, staleness detected): stale node quarantines; after sync, rejects
635    - Integration (enforce, no staleness signal): stale node accepts (irreducible risk); after sync, rejects
636    - Integration: node with identity repo below `min_kel_seq` -> `Rejected` in both modes
637    - Tamper: forged KEL event mid-chain -> `validate_kel()` fails -> `Rejected` regardless of mode
638  - *Affected areas:* `crates/auths-radicle/tests/`
639  
640  ---
641  
642  ### Epic 5: Minimal Radicle Integration Seam
643  
644  **Objective:** Wire the bridge into Heartwood's fetch pipeline at the point defined by fn-3 and fn-4.
645  
646  **Why it matters:** This is where the bridge actually gets called. The seam must align with Heartwood's existing epic plan.
647  
648  **Success metrics:**
649  - `DeviceAuthorization` (fn-3) calls `auths-radicle::DefaultBridge::verify_signer()`
650  - Fetch pipeline (fn-4) passes enforcement mode and gossip tip to the bridge
651  - Projects without KERI delegates are unaffected
652  
653  **Exit criteria:**
654  - Heartwood's existing test suite passes
655  - Projects with KERI delegates trigger bridge verification
656  - Observe/enforce mode is respected in the fetch pipeline
657  
658  #### Tasks
659  
660  **Task 5.1: Wire `DefaultBridge` into `DeviceAuthorization` (fn-3.2 counterpart)**
661  - *Why:* fn-3.2 in Heartwood creates `CompositeAuthorityChecker` which calls into auths-radicle. This task ensures the bridge API matches what Heartwood expects.
662  - *Acceptance criteria:* `DefaultBridge<RadicleAuthsStorage>::verify_signer()` signature is compatible with fn-3.2's expected API. Accepts `node_id: &[u8; 32]`, `repo_id: &str`, `now: DateTime<Utc>`, `enforcement_mode: Mode`, `known_remote_tip: Option<Oid>`.
663  - *Test plan:*
664    - Unit: mock Heartwood caller invokes bridge with correct parameter types
665    - Integration: end-to-end from `DeviceAuthorization::is_authorized()` through bridge to result
666  - *Affected areas:* `crates/auths-radicle/src/bridge.rs`, `crates/auths-radicle/src/verify.rs`
667  
668  **Task 5.2: Pass gossip state to bridge in fetch pipeline (fn-4 counterpart)**
669  - *Why:* The bridge needs the `known_remote_tip` from gossip to detect staleness. This is available in the fetch pipeline from `RefsAnnouncement` data.
670  - *Acceptance criteria:* When the fetch pipeline calls the bridge, it includes the latest announced tip OID for the identity repo (if known from prior `RefsAnnouncement`s).
671  - *Test plan:*
672    - Integration: fetch with gossip-announced identity repo tip -> bridge receives it
673    - Integration: fetch without prior gossip data -> bridge receives `None`
674  - *Affected areas:* Heartwood: `crates/radicle-fetch/src/state.rs`. Auths: `crates/auths-radicle/src/bridge.rs`
675  
676  ---
677  
678  ### Epic 6: End-to-End Demo and Test Scenarios
679  
680  **Objective:** Prove the integration works in realistic scenarios including the stale-node case.
681  
682  **Why it matters:** End-to-end tests are the only way to prove the system works across crate boundaries and under realistic conditions.
683  
684  **Success metrics:**
685  - All three demo scenarios (authorization, revocation, stale node) pass under both modes
686  - Tests run in CI without external dependencies
687  
688  **Exit criteria:**
689  - End-to-end test suite passes on all CI platforms
690  - Demo script is executable and produces documented output
691  
692  #### Tasks
693  
694  **Task 6.1: Multi-device authorization end-to-end test**
695  - *Why:* Proves device authorization works through the full stack
696  - *Acceptance criteria:* Test creates a KERI identity (inception event + KEL), creates a 2-way attestation in RIP-X format, creates a project with a DID namespace, signs an update from the authorized device, and verifies the bridge accepts it.
697  - *Test plan:*
698    - E2E: full flow from identity creation to update acceptance
699    - E2E: unauthorized device (no attestation) is rejected
700    - E2E: device with wrong capabilities is rejected
701  - *Affected areas:* `crates/auths-radicle/tests/`
702  
703  **Task 6.2: Revocation end-to-end test**
704  - *Why:* Proves revocation stops a device from being authorized
705  - *Acceptance criteria:* Test creates identity, authorizes device, verifies acceptance, revokes device, verifies rejection. Tests under both observe and enforce modes.
706  - *Test plan:*
707    - E2E: device accepted before revocation, rejected after (enforce mode)
708    - E2E: device accepted before revocation, warned after (observe mode)
709    - E2E: revocation of one device does not affect other authorized devices
710    - E2E: re-authorization after revocation (new attestation) works
711  - *Affected areas:* `crates/auths-radicle/tests/`
712  
713  **Task 6.3: Stale-node end-to-end test**
714  - *Why:* Proves the system behaves safely under the core adversarial scenario
715  - *Acceptance criteria:* Test simulates two nodes with different identity state and gossip knowledge. Verifies correct behavior per enforcement mode.
716  - *Test plan:*
717    - E2E (observe): stale node accepts with `Warn`, converges to `Rejected` after sync
718    - E2E (enforce, staleness detected): stale node quarantines, resolves after sync
719    - E2E (enforce, no staleness signal): stale node accepts (irreducible risk, document this)
720    - E2E: node with identity repo below `min_kel_seq` -> hard reject in both modes
721    - Tamper: forged KEL event -> `Rejected` regardless of mode
722  - *Affected areas:* `crates/auths-radicle/tests/`
723  
724  **Task 6.4: CI integration**
725  - *Why:* All tests must run in CI to prevent regressions
726  - *Acceptance criteria:* CI workflow runs the auths-radicle test suite. Tests work on Ubuntu, macOS, and Windows.
727  - *Test plan:*
728    - CI: all tests pass on all three platforms
729    - CI: tests complete within reasonable time (< 5 minutes)
730  - *Affected areas:* `.github/workflows/`, `crates/auths-radicle/`
731  
732  ---
733  
734  ### PR Slicing Plan
735  
736  The work is sliced into 10 PRs, ordered by dependency. PRs target the **auths** repository unless noted.
737  
738  | PR | Title | Epic | Description | Depends on |
739  |----|-------|------|-------------|------------|
740  | **PR 1** | RIP-X ref path constants and attestation format | 1 | `refs.rs` with RIP-X constants. `Attestation::to_bytes()` / `from_bytes()` for 2-blob format. | None |
741  | **PR 2** | `GitKel::with_ref()` for custom KEL ref paths | 1 | Allow auths-id's KEL reader to use `refs/keri/kel` instead of default path. | None |
742  | **PR 3** | `AuthsStorage` impl for Radicle-replicated repos | 2 | Load KEL + attestations from identity repos in RIP-X layout. | PR 1, PR 2 |
743  | **PR 4** | `find_identity_for_device()` and DID namespace scanning | 2 | Scan project namespaces to find which identity a device belongs to. | PR 3 |
744  | **PR 5** | Full authorization pipeline | 3 | Wire `verify_signer()` through the Radicle-backed storage. All auth scenarios tested. | PR 3, PR 4 |
745  | **PR 6** | Observe/enforce modes + gossip-informed staleness | 4 | Enforcement mode config, `known_remote_tip` staleness, `min_kel_seq` binding integrity. | PR 5 |
746  | **PR 7** | Stale-state integration tests | 4 | Node A vs Node B simulation under both modes. Tamper tests. | PR 6 |
747  | **PR 8** | Bridge API alignment with Heartwood fn-3.2 | 5 | Ensure bridge signature matches `DeviceAuthorization` expectations. Gossip tip passthrough. | PR 6 |
748  | **PR 9** | End-to-end tests | 6 | Full E2E: authorization, revocation, stale-node under both modes. | PR 7, PR 8 |
749  | **PR 10** | CI integration and demo script | 6 | CI workflow, demo documentation, cross-platform verification. | PR 9 |
750  
751  ```
752  PR 1 ──┐
753  PR 2 ──┼──► PR 3 ──► PR 4 ──► PR 5 ──► PR 6 ──► PR 7 ──► PR 9 ──► PR 10
754         │                                  │                  ▲
755         │                                  └──► PR 8 ────────┘
756  ```
757  
758  ---
759  
760  ## 8. Demo Script
761  
762  **Title:** "Multi-device authorization, revocation, and stale-node safety in 5 minutes"
763  
764  **Prerequisites:** Two terminal windows (simulating two nodes). Radicle CLI with KERI support installed (fn-5 commands available). Both nodes running (`rad node start`).
765  
766  ---
767  
768  **Act 1: Setup and Authorization (Node A = Alice, Node B = Bob)**
769  
770  > "Alice creates her KERI identity and authorizes her laptop."
771  
772  On Node A:
773  ```
774  rad identity init-keri
775  # Shows: Created KERI identity did:keri:EXq5...
776  # Shows: Identity repo rad:z3gq... seeded to network
777  # Shows: Current device automatically attested (KEL sequence 1)
778  
779  rad identity device add --key <laptop-nid>
780  # Shows: 2-way attestation created for <laptop-nid>
781  # Shows: Attestation anchored at KEL sequence 2
782  # Shows: Run `rad identity device confirm` on the laptop to complete
783  ```
784  
785  On Laptop (complete 2-way attestation):
786  ```
787  rad identity device confirm --identity did:keri:EXq5...
788  # Shows: Device attestation signed and stored
789  ```
790  
791  > "She creates a project with her KERI identity as a delegate."
792  
793  On Node A:
794  ```
795  mkdir my-project && cd my-project && git init && echo "hello" > README.md && git add . && git commit -m "init"
796  rad init --name "my-project"
797  rad id update --allow did:keri:EXq5...
798  # Shows: Project delegate added: did:keri:EXq5...
799  # Shows: DID namespace refs/namespaces/did-keri-EXq5.../refs/rad/id -> rad:z3gq...
800  ```
801  
802  > "Bob seeds Alice's project. His node auto-discovers and fetches the identity repo."
803  
804  On Node B:
805  ```
806  rad seed rad:<project-rid>
807  rad sync --fetch
808  # Shows: Fetched project rad:<project-rid>
809  # Shows: Discovered KERI identity did:keri:EXq5... -> fetching identity repo rad:z3gq...
810  # Shows: Identity repo fetched, KEL at sequence 2
811  ```
812  
813  > "Alice's laptop pushes a commit."
814  
815  On Laptop:
816  ```
817  cd my-project
818  echo "feature code" > feature.rs
819  git add . && git commit -m "Add feature"
820  git push rad main
821  ```
822  
823  > "Bob's node accepts it -- the laptop is authorized via the KERI identity."
824  
825  On Node B:
826  ```
827  rad sync --fetch
828  # Shows: Fetched 1 update for my-project
829  # Shows: Signer <laptop-nid> VERIFIED (attested under did:keri:EXq5...)
830  ```
831  
832  **Act 2: Revocation**
833  
834  > "Alice discovers her laptop was compromised. She revokes it."
835  
836  On Node A:
837  ```
838  rad identity device revoke <laptop-nid>
839  # Shows: Device <laptop-nid> revoked at 2026-03-01T14:00:00Z
840  # Shows: Revocation anchored at KEL sequence 3
841  # Shows: Updated identity repo pushed to network
842  ```
843  
844  > "Bob syncs and gets the revocation."
845  
846  On Node B:
847  ```
848  rad sync --fetch
849  # Shows: Identity repo rad:z3gq... updated to KEL sequence 3
850  # Shows: Device <laptop-nid> marked as revoked
851  ```
852  
853  > "The compromised laptop tries to push again."
854  
855  On Laptop (simulating compromised device):
856  ```
857  echo "malicious code" > backdoor.rs
858  git add . && git commit -m "Innocent update"
859  git push rad main
860  ```
861  
862  > "Bob's node rejects it (enforce mode)."
863  
864  On Node B:
865  ```
866  rad sync --fetch
867  # Shows: Signer <laptop-nid> REJECTED (device revoked at 2026-03-01T14:00:00Z)
868  # Shows: 0 updates applied from <laptop-nid>
869  ```
870  
871  **Act 3: Stale Node Safety**
872  
873  > "Charlie is a new node that seeds the project but hasn't yet fetched the latest identity repo."
874  
875  On Node C (or simulate by pausing identity repo sync on Node B):
876  ```
877  rad seed rad:<project-rid>
878  # Charlie fetches the project but his identity repo is stale (sequence 2, pre-revocation)
879  # Meanwhile, gossip has announced a newer identity repo tip...
880  ```
881  
882  > "The compromised laptop pushes to Charlie."
883  
884  ```
885  rad sync --fetch
886  ```
887  
888  **Enforce mode (recommended):**
889  ```
890  # Shows: Signer <laptop-nid> QUARANTINED
891  # Shows: Identity repo rad:z3gq... has newer tip available (announced via gossip)
892  # Shows: Fetch identity repo before accepting updates from KERI-attested devices
893  # Shows: 0 updates applied
894  ```
895  
896  > "Charlie fetches the identity repo and the quarantine resolves."
897  
898  ```
899  rad sync --fetch  # Now fetches identity repo
900  # Shows: Identity repo rad:z3gq... updated to KEL sequence 3
901  # Shows: Device <laptop-nid> is revoked -- quarantined update would have been rejected anyway
902  ```
903  
904  **Observe mode (alternative):**
905  ```
906  # Shows: Signer <laptop-nid> VERIFIED (WARNING: identity state may be stale)
907  # Shows: Warning: identity repo rad:z3gq... has newer tip available
908  # Shows: 1 update applied with warning
909  
910  # After syncing identity repo:
911  # Shows: Device <laptop-nid> revoked -- previously-accepted update flagged for review
912  ```
913  
914  > "Key takeaways:"
915  > 1. "In enforce mode, the stale node quarantined the update because it knew (via gossip) that its identity state was behind. No malicious data was accepted."
916  > 2. "In observe mode, the update was accepted but flagged. The system detected and logged the risk. After convergence, all nodes agree."
917  > 3. "The irreducible risk: a fully-disconnected node with no gossip signal cannot know it's stale. This is fundamental to any eventually-consistent system. The mitigation is to seed identity repos and maintain gossip connectivity."