diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index fc7473a64e..f8358c6af7 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -1145,6 +1145,29 @@ Plans:
2. Scope-exit re-mint binds the new read key to a verified recipient public key (pinned/verified rather than blindly server-supplied), and refetches `/shares/sent` once per rotation job (cached), not once per rotated node.
3. TS `rotatedNodes` stores a defensive 32-byte copy of `readKey` (no aliasing with `parentNewReadKey`), matching Rust parity.
+**Plans**: 8 plans (4 waves)
+
+Plans:
+**Wave 1**
+
+- [ ] 80-01-PLAN.md — D-03b: NodeWriteBody recipientPins field + conditional-emit codec + cross-language JSON KAT + schema doc (wave 1)
+- [ ] 80-02-PLAN.md — D-01/D-02: FUSE write-body reconstruction + job-scoped /shares/sent cache + replay durability regression (wave 1)
+- [ ] 80-03-PLAN.md — D-04/D-02: TS rotatedNodes defensive copy + owner-reconcile listSentGrants cache (wave 1)
+
+**Wave 2** *(blocked on Wave 1 completion)*
+
+- [ ] 80-04-PLAN.md — D-03a/c: sdk-core pin write/read/verify helpers + pin-preserving publish + client wrappers (wave 2)
+- [ ] 80-05-PLAN.md — D-03a/D-01: Rust pin plumbing (ResolvedOwnedChild + InodeTable cache + reconstruction preservation) (wave 2)
+
+**Wave 3** *(blocked on Wave 2 completion)*
+
+- [ ] 80-06-PLAN.md — D-03d/e: Rust re-mint fail-closed pin enforcement + get_recipient_pubkey_pins seam (wave 3)
+- [ ] 80-07-PLAN.md — D-03d/e: TS re-mint fail-closed pin enforcement + getPinsFn seam (wave 3)
+
+**Wave 4** *(blocked on Wave 3 completion)*
+
+- [ ] 80-08-PLAN.md — D-03c/d: web issuance pin write + upgrade/reconcile fail-closed enforcement (wave 4)
+
---
### Phase 81: TEE Republish and IPNS-Record Correctness
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 56af7c0c72..b3f52dd3c6 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -6,15 +6,14 @@ current_phase: 78
current_phase_name: recovery-tool-v3-vault-load-guards-web-ux-and-ci-guards
status: executing
stopped_at: Completed 77-09-PLAN.md
-last_updated: "2026-07-12T01:02:55.794Z"
+last_updated: "2026-07-12T19:28:11.050Z"
last_activity: 2026-07-12
-last_activity_desc: Phase 78 execution started
progress:
- total_phases: 22
- completed_phases: 19
- total_plans: 218
- completed_plans: 210
- percent: 86
+ total_phases: 26
+ completed_phases: 23
+ total_plans: 239
+ completed_plans: 239
+ percent: 88
---
# Project State
@@ -30,8 +29,8 @@ See: .planning/PROJECT.md (updated 2026-06-27)
Phase: 78 (recovery-tool-v3-vault-load-guards-web-ux-and-ci-guards) — EXECUTING
Plan: 1 of 8
-Status: Executing Phase 78
-Last activity: 2026-07-12 — Phase 78 execution started
+Status: Ready to execute
+Last activity: 2026-07-12
Progress: `██████████` 79 / 79 plans (100%)
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-PLAN.md
new file mode 100644
index 0000000000..4a7bd4adb4
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-PLAN.md
@@ -0,0 +1,250 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 01
+type: tdd
+wave: 1
+depends_on: []
+files_modified:
+ - crates/core/src/node/types.rs
+ - crates/core/src/node/encode.rs
+ - crates/core/src/node/decode.rs
+ - crates/core/tests/node_write_body_vectors.rs
+ - packages/core/src/node/types.ts
+ - packages/core/src/node/encode.ts
+ - packages/core/src/node/decode.ts
+ - packages/core/src/__tests__/node-codec-vectors.test.ts
+ - tests/vectors/node-codec.json
+ - docs/METADATA_SCHEMAS.md
+autonomous: true
+requirements:
+ - "SC2 / D-03a / D-03b: recipient-pubkey pin field on NodeWriteBody with Rust/TS wire parity"
+user_setup: []
+
+must_haves:
+ truths:
+ - "NodeWriteBody carries an optional recipientPins list that round-trips byte-identically in Rust and TS (D-03b)"
+ - "The frozen seal_vectors[0] KAT (empty-pin write-body) still passes unchanged — the pin field is omitted from the wire when empty (D-03b, Pitfall 1)"
+ - "A new seal_vectors[1] KAT with a non-empty pin list is asserted byte-for-byte on both sides (D-03b lockstep)"
+ - "The Phase-78 recovery tool (apps/web/recovery-src) still tolerates the new field — it never parses NodeWriteBody (D-03b verified no-op)"
+ artifacts:
+ - "crates/core/src/node/types.rs — NodeWriteBody.recipient_pins field"
+ - "packages/core/src/node/types.ts — NodeWriteBody.recipientPins field"
+ - "tests/vectors/node-codec.json — seal_vectors[1] fixture with non-empty recipientPins"
+ - "docs/METADATA_SCHEMAS.md — NodeWriteBody recipientPins documented + version-history row"
+ key_links:
+ - "encode_write_body / encodeWriteBody conditional emission (omit when empty) preserves seal_vectors[0]"
+ - "decode_write_body / decodeWriteBody tolerate absent field (default to empty), never throw on it"
+ prohibitions:
+ - "MUST NOT add #[serde(deny_unknown_fields)] to NodeWriteBody (forward tolerance; unlike SealedChildRef) (D-03b, Anti-Pattern)"
+ - "MUST NOT emit the pin field unconditionally — that changes seal_vectors[0] frozen bytes (Pitfall 1)"
+ - "MUST NOT make the TS pin field required — existing test literals { ipnsPrivateKey, writeChildren } must still compile (Pitfall 2)"
+ - "MUST NOT invent a CBOR encoder — the write-body wire format is plaintext canonical JSON, then AEAD-sealed (PATTERNS correction)"
+ - "MUST NOT bump generation or add a 'pin generation' counter — this field rides inside the existing role-0x01 write-body seal (Anti-Pattern)"
+---
+
+
+Add an optional recipient-pubkey pin list to `NodeWriteBody` — the owner-sealed, IPNS-published,
+server-opaque store that D-03 uses to verify recipient identity at re-mint. This is a
+metadata-schema change (D-03b) to the encrypted node codec (NOT a DB/TypeORM schema, NOT an
+API/DTO change → no `pnpm api:generate`). It is the foundational dependency for the pin
+issuance write (80-04) and all three fail-closed enforcement consumers (80-06/07/08).
+
+The write-body wire format is **plaintext canonical JSON** (`encode_write_body` /
+`encodeWriteBody`), then AEAD-sealed under the writeKey with role byte `0x01` (`seal_node`).
+The parity test is a **JSON KAT** — a new `seal_vectors[1]` entry in the shared oracle
+`tests/vectors/node-codec.json` — NOT a CBOR contract test.
+
+Purpose: server-opaque, cross-device recipient-pubkey binding that a compromised relay cannot forge.
+Output: `NodeWriteBody.recipientPins` (Rust + TS), conditional-emit codec, byte-locked cross-language KAT, updated schema doc.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@docs/METADATA_EVOLUTION_PROTOCOL.md
+@docs/METADATA_SCHEMAS.md
+@crates/core/src/node/types.rs
+@crates/core/src/node/encode.rs
+@crates/core/tests/node_write_body_vectors.rs
+@packages/core/src/node/encode.ts
+@packages/core/src/__tests__/node-codec-vectors.test.ts
+@tests/vectors/node-codec.json
+
+
+
+
+
+ Task 1: RED — add seal_vectors[1] fixture and failing cross-language KAT for a non-empty recipientPins
+ tests/vectors/node-codec.json, crates/core/tests/node_write_body_vectors.rs, packages/core/src/__tests__/node-codec-vectors.test.ts
+
+ - tests/vectors/node-codec.json (lines 94-135 — the existing seal_vectors[0] structure: node_id, kind, generation, read_key, write_key, ipns_private_key_hex, fixed_iv, expected_published_node.writeSealed)
+ - crates/core/tests/node_write_body_vectors.rs (full file — `write_body_seal_matches_kat` iterates ALL seal_vectors via `for v in &vectors.seal_vectors` and hardcodes `write_children: Vec::new()` with NO pin; the SealVector deserialize struct at lines 44-58)
+ - packages/core/src/__tests__/node-codec-vectors.test.ts (lines 201-256 — the seal_vectors[0] readSealed/writeSealed assertions index `VECTORS.seal_vectors[0]` explicitly)
+ - crates/crypto/tests/cross_language.rs (lines 272-320 — CONFIRM this reads `crypto/node-aad.json` NOT `node-codec.json`; its `assert_eq!(seal_vectors.len(), 1)` guard at line 310 is a DIFFERENT file's seal_vectors and must stay untouched/green)
+
+
+ - New `seal_vectors[1]` in node-codec.json: same fixed key/IV convention, `write_children: []`, plus a non-empty `recipientPins` list (2 raw compressed secp256k1 pubkeys, base64-encoded in the JSON to match the write-body's existing binary-field convention), and an `expected_published_node.writeSealed` computed for that pinned body.
+ - Rust `node_write_body_vectors.rs::write_body_seal_matches_kat` extended: the SealVector struct gains a `recipient_pins` field; the loop populates `NodeWriteBody.recipient_pins` from the vector (empty for [0], non-empty for [1]) so it reproduces BOTH writeSealed values byte-for-byte.
+ - New TS test block reads `VECTORS.seal_vectors[1]`, reconstructs the pinned write-body, seals under the fixed key/IV, and asserts `reconstructedWriteSealed === sv.expected_published_node.writeSealed`.
+ - Both tests FAIL initially (the codec does not yet know the field) — this is the RED state.
+
+
+ Add the `seal_vectors[1]` fixture to tests/vectors/node-codec.json using the SAME fixed key/IV
+ discipline as `seal_vectors[0]` (reuse the node-aad.json node_id/IV convention). Store `recipientPins`
+ as an array of base64 strings (raw compressed 33-byte pubkeys) to mirror the existing `base64_key`
+ convention for `ipnsPrivateKey`. Leave the `expected_published_node.writeSealed` value as a
+ placeholder to be filled once the GREEN codec exists (Task 2/3 regenerate it), OR compute it now with
+ a scratch script — either way the test must assert against the committed value, never skip.
+ Extend `node_write_body_vectors.rs`'s SealVector deserialize struct with `recipient_pins: Vec`
+ and populate `NodeWriteBody { ipns_private_key, write_children: vec![], recipient_pins: }`
+ in the loop. Add the mirrored TS `seal_vectors[1]` assertion block after the existing seal_vectors[0]
+ block in node-codec-vectors.test.ts. Do NOT modify the frozen seal_vectors[0] entry. Do NOT touch
+ crates/crypto/tests/cross_language.rs (it reads node-aad.json — verify by grep, then leave it).
+
+
+ cargo test -p cipherbox-core --test node_write_body_vectors 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed"
+
+
+ - tests/vectors/node-codec.json contains a `seal_vectors[1]` object whose `recipientPins` array is non-empty (length >= 2)
+ - `grep -n "recipient_pins" crates/core/tests/node_write_body_vectors.rs` shows the field wired into the loop's NodeWriteBody construction
+ - `grep -n "seal_vectors\[1\]\|seal_vectors[1]" packages/core/src/__tests__/node-codec-vectors.test.ts` shows a new assertion block
+ - The extended Rust KAT and new TS test FAIL before Task 2/3 (RED), proving they are not vacuous
+ - `grep -c "node-codec.json" crates/crypto/tests/cross_language.rs` returns 0 (cross_language.rs reads node-aad.json — unaffected)
+
+ Both the Rust and TS pin KATs exist and fail against the current (pin-unaware) codec; seal_vectors[0] and cross_language.rs are untouched.
+
+
+
+ Task 2: GREEN (Rust) — add recipient_pins to NodeWriteBody with conditional emission and tolerant decode
+ crates/core/src/node/types.rs, crates/core/src/node/encode.rs, crates/core/src/node/decode.rs, tests/vectors/node-codec.json
+
+ - crates/core/src/node/types.rs (lines 131-145 — `NodeWriteBody` struct; note it has NO `#[serde(deny_unknown_fields)]` unlike `SealedChildRef` at line 100 — preserve that)
+ - crates/core/src/node/encode.rs (lines 110-124 `encode_write_body` = `serde_json::to_vec(wb)`; the `#[cfg(test)] mod write_body_tests` round-trip at lines 126-139)
+ - crates/core/src/node/decode.rs (lines 113-118 `decode_write_body` = `serde_json::from_slice`)
+
+
+ - `NodeWriteBody.recipient_pins: Vec>` (each entry a raw compressed pubkey), serialized to
+ camelCase `recipientPins` as an array of base64 strings, with `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so an empty list is OMITTED from the wire (preserves seal_vectors[0]).
+ - `decode_write_body` on a document with no `recipientPins` yields `recipient_pins: []` (serde default), never errors.
+ - Round-trip unit tests cover BOTH the empty-pin (default) and non-empty-pin variants.
+ - After this task, seal_vectors[0] KAT still passes; seal_vectors[1] Rust KAT now passes.
+
+
+ Add `recipient_pins` to `NodeWriteBody` with a base64-list serde helper (reuse the existing
+ `base64_key`-style module, or add a `base64_key_list` sibling; each element is a raw pubkey byte
+ vector). Apply `#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "recipientPins")]`
+ (or place `rename_all = camelCase` coverage) so the field is omitted when empty. Do NOT add
+ `deny_unknown_fields`. Extend the existing `write_body_tests` round-trip (encode.rs) to assert a
+ populated `recipient_pins` survives encode→decode AND that an empty list encodes to bytes identical
+ to the pre-change output (the seal_vectors[0] preservation guarantee). Regenerate the
+ `seal_vectors[1].expected_published_node.writeSealed` placeholder from Task 1 if it was left as a
+ placeholder (compute via the KAT's own fixed-key/IV seal path, commit the real value).
+
+
+ cargo test -p cipherbox-core --test node_write_body_vectors 2>&1 | grep -q "test result: ok" && cargo test -p cipherbox-core node encode decode 2>&1 | tail -5
+
+
+ - `grep -n "recipient_pins" crates/core/src/node/types.rs` shows the field with `skip_serializing_if = "Vec::is_empty"` and no `deny_unknown_fields` anywhere on the struct
+ - `cargo test -p cipherbox-core --test node_write_body_vectors` passes for BOTH seal_vectors[0] and seal_vectors[1]
+ - A round-trip unit test in encode.rs asserts an empty `recipient_pins` encodes byte-identically to the frozen pre-change output
+ - `decode_write_body` on JSON lacking `recipientPins` returns an empty list (add/keep a test asserting no error)
+
+ Rust NodeWriteBody carries recipient_pins with conditional emission; both KAT vectors and round-trip tests pass; no deny_unknown_fields added.
+
+
+
+ Task 3: GREEN (TS) — mirror recipientPins in the TS codec and document the schema change
+ packages/core/src/node/types.ts, packages/core/src/node/encode.ts, packages/core/src/node/decode.ts, docs/METADATA_SCHEMAS.md
+
+ - packages/core/src/node/types.ts (lines ~135-140 — `NodeWriteBody` type; `writeBody?` is optional on Node)
+ - packages/core/src/node/encode.ts (lines 140-155 `encodeWriteBody` builds `{ ipnsPrivateKey, writeChildren }` as an object literal — add the pin key ONLY when non-empty)
+ - packages/core/src/node/decode.ts (lines 317-364 `decodeWriteBody` manual validation — default the new field to [] when absent, never throw)
+ - packages/core/src/__tests__/node-codec-vectors.test.ts (lines 244, 340-344 — existing `{ ipnsPrivateKey, writeChildren: [] }` object literals that must still type-check → field MUST be optional)
+ - docs/METADATA_EVOLUTION_PROTOCOL.md (§3.1 additive-change contract, §6.2/§6.4 lockstep rule) and docs/METADATA_SCHEMAS.md (current NodeWriteBody section + version-history table)
+
+
+ - `NodeWriteBody.recipientPins?: string[]` (base64, optional) in types.ts.
+ - `encodeWriteBody` includes `recipientPins` in the wire object ONLY when the list is present and
+ non-empty (byte-preserving for seal_vectors[0]); order matches the Rust serde field order.
+ - `decodeWriteBody` defaults absent/empty `recipientPins` to `[]`, never throws on it.
+ - seal_vectors[0] TS KAT still passes; seal_vectors[1] TS KAT now passes.
+ - docs/METADATA_SCHEMAS.md documents the new field and gains a version-history row.
+
+
+ Add optional `recipientPins?: string[]` to the TS `NodeWriteBody` type. In `encodeWriteBody`, spread
+ the pin key conditionally (only when `recipientPins?.length`), matching the Rust field order so the
+ JSON bytes are identical across languages. In `decodeWriteBody`, read `recipientPins` with the same
+ manual-validation style used for `writeChildren`, defaulting to `[]`. Update docs/METADATA_SCHEMAS.md:
+ add `recipientPins` to the NodeWriteBody schema block and append a version-history row per
+ METADATA_EVOLUTION_PROTOCOL §6. Markdownlint applies to docs/ (NOT excluded like .planning/): use
+ `###` headings, blank lines around lists/fences.
+
+
+ pnpm --filter @cipherbox/core test node-codec-vectors 2>&1 | tail -15
+
+
+ - `grep -n "recipientPins" packages/core/src/node/types.ts` shows an OPTIONAL field (`?:`)
+ - `pnpm --filter @cipherbox/core test node-codec-vectors` passes for BOTH seal_vectors[0] and seal_vectors[1]
+ - The pre-existing `{ ipnsPrivateKey, writeChildren: [] }` literals still type-check (no required-field break)
+ - `grep -rn "writeKey\|writeSealed\|NodeWriteBody\|recipientPins" apps/web/recovery-src/` returns zero matches — recovery tool never parses the write-body, so it tolerates the field by construction (D-03b verified no-op)
+ - docs/METADATA_SCHEMAS.md shows `recipientPins` documented plus a new version-history row; `pnpm --filter @cipherbox/core typecheck` passes
+
+ TS NodeWriteBody mirrors the Rust field byte-for-byte; both KATs green cross-language; recovery-tool tolerance verified; schema doc updated.
+
+
+
+
+
+New/changed symbols and fixtures this plan introduces (consumed by 80-04/05/06/07/08):
+- `NodeWriteBody.recipient_pins: Vec>` (Rust, `recipientPins` base64 on the wire)
+- `NodeWriteBody.recipientPins?: string[]` (TS)
+- `tests/vectors/node-codec.json` → `seal_vectors[1]` (non-empty-pin KAT fixture)
+- docs/METADATA_SCHEMAS.md NodeWriteBody `recipientPins` documentation + version-history row
+- Encoding convention: pins stored as base64 strings on the wire, normalized to raw pubkey bytes for the D-03d compare
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| owner device → IPFS/relay | Write-body sealed under owner writeKey; relay stores opaque bytes |
+| older-schema reader → new-schema document | Phase-78 recovery tool and any pre-field reader must not fail-closed on the new field |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-01 | Tampering | seal_vectors[0] frozen KAT | high | mitigate | Conditional emission (skip when empty) keeps frozen bytes; new seal_vectors[1] locks the pinned path |
+| T-80-02 | Denial of Service | decode_write_body / decodeWriteBody | medium | mitigate | Tolerant decode (default []), no deny_unknown_fields — older/newer readers never fail-closed on the field |
+| T-80-03 | Information Disclosure | recipientPins content | low | accept | Pins are recipient PUBLIC keys inside an owner-sealed body — no secret material added |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `cargo test -p cipherbox-core` green (codec + both KAT vectors + round-trip)
+- `pnpm --filter @cipherbox/core test` green (node-codec-vectors, both vectors)
+- `pnpm --filter @cipherbox/core typecheck` green
+- crates/crypto/tests/cross_language.rs untouched and still green (reads node-aad.json)
+- recovery-src grep confirms zero NodeWriteBody parsing
+
+
+
+NodeWriteBody carries an optional recipientPins list with byte-identical Rust/TS wire parity, the
+frozen empty-pin KAT is preserved via conditional emission, a new non-empty-pin KAT is locked on both
+sides, and the schema doc reflects the additive change — with no deny_unknown_fields and no API/DB change.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md
new file mode 100644
index 0000000000..d6b12677f1
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md
@@ -0,0 +1,175 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 01
+subsystem: crypto
+tags: [node-codec, serde, recipient-pins, cross-language-kat, metadata-schema, d-03b]
+
+# Dependency graph
+requires:
+ - phase: 62-node-codec
+ provides: node/v3 NodeWriteBody codec, tests/vectors/node-codec.json seal_vectors[0]
+ - phase: 69-rust-node-twin
+ provides: crates/core node codec Rust twin + write-body seal KAT
+provides:
+ - "NodeWriteBody.recipient_pins (Rust Vec>) / recipientPins? (TS string[]) optional pin field"
+ - "Conditional-emit codec (omit when empty) preserving frozen seal_vectors[0] bytes"
+ - "seal_vectors[1] non-empty-pin cross-language KAT (Rust + TS byte-locked)"
+ - "METADATA_SCHEMAS.md NodeWriteBody recipientPins documentation + version-history row"
+affects: [80-04, 80-05, 80-06, 80-07, 80-08]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "base64_key_list serde helper: Vec> <-> JSON array of base64 strings"
+ - "Additive optional metadata field via skip_serializing_if=Vec::is_empty (Rust) + conditional spread (TS)"
+
+key-files:
+ created: []
+ modified:
+ - crates/core/src/node/types.rs
+ - crates/core/src/node/encode.rs
+ - crates/core/src/node/seal.rs
+ - crates/core/tests/node_write_body_vectors.rs
+ - packages/core/src/node/types.ts
+ - packages/core/src/node/encode.ts
+ - packages/core/src/node/decode.ts
+ - packages/core/src/__tests__/node-codec-vectors.test.ts
+ - tests/vectors/node-codec.json
+ - docs/METADATA_SCHEMAS.md
+
+key-decisions:
+ - "TS decode attaches recipientPins ONLY when non-empty (symmetric with encode), keeping existing writeBody round-trip toEqual green without mutating prior test literals"
+ - "recipient_pins stored as raw pubkey bytes in-memory, base64 array on the wire, matching the existing ipnsPrivateKey base64 convention"
+ - "Field order fixed as ipnsPrivateKey, writeChildren, recipientPins in both codecs for byte-identical cross-language wire"
+
+patterns-established:
+ - "base64_key_list: sibling of base64_key for a JSON array of base64-encoded byte vectors"
+ - "Empty additive list is omitted from the wire on BOTH sides to preserve frozen golden vectors"
+
+requirements-completed: ["SC2 / D-03a / D-03b: recipient-pubkey pin field on NodeWriteBody with Rust/TS wire parity"]
+
+coverage:
+ - id: D1
+ description: "NodeWriteBody carries an optional recipientPins list that round-trips byte-identically in Rust and TS (non-empty-pin path locked by seal_vectors[1])"
+ requirement: "SC2 / D-03a / D-03b: recipient-pubkey pin field on NodeWriteBody with Rust/TS wire parity"
+ verification:
+ - kind: unit
+ ref: "crates/core/tests/node_write_body_vectors.rs#write_body_seal_matches_kat"
+ status: pass
+ - kind: unit
+ ref: "packages/core/src/__tests__/node-codec-vectors.test.ts#folder node writeSealed with non-empty recipientPins matches frozen vector [1] (D-03b)"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "Frozen empty-pin KAT seal_vectors[0] preserved byte-for-byte via conditional emission (field omitted when empty)"
+ verification:
+ - kind: unit
+ ref: "crates/core/src/node/encode.rs#write_body_round_trip_empty_children"
+ status: pass
+ - kind: unit
+ ref: "packages/core/src/__tests__/node-codec-vectors.test.ts#folder node writeSealed base64 matches frozen vector"
+ status: pass
+ human_judgment: false
+ - id: D3
+ description: "Tolerant decode: write-body lacking recipientPins decodes to empty (Rust []) / absent (TS) and never throws"
+ verification:
+ - kind: unit
+ ref: "crates/core/src/node/encode.rs#decode_write_body_defaults_missing_recipient_pins_to_empty"
+ status: pass
+ human_judgment: false
+ - id: D4
+ description: "METADATA_SCHEMAS.md documents recipientPins as an additive optional field with a version-history row"
+ verification:
+ - kind: manual_procedural
+ ref: "docs/METADATA_SCHEMAS.md §8 NodeWriteBody + §3 version history; markdownlint pass"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 25min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 01: NodeWriteBody recipientPins Codec Field Summary
+
+**Additive optional `recipientPins` pin list on `NodeWriteBody` (Rust `Vec>` / TS `string[]`) with conditional-emit codec, a new byte-locked cross-language `seal_vectors[1]` KAT, and the frozen empty-pin `seal_vectors[0]` preserved unchanged.**
+
+## Performance
+
+- **Duration:** ~25 min
+- **Started:** 2026-07-12T17:10:00Z
+- **Completed:** 2026-07-12T17:35:30Z
+- **Tasks:** 3 (RED fixture + failing KATs; GREEN Rust; GREEN TS + docs)
+- **Files modified:** 10
+
+## Accomplishments
+
+- Added `recipient_pins: Vec>` to Rust `NodeWriteBody` with a new `base64_key_list` serde helper and `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so an empty list is omitted from the wire (no `deny_unknown_fields`).
+- Mirrored `recipientPins?: string[]` in the TS codec: `encodeWriteBody` spreads the key only when non-empty; `decodeWriteBody` validates when present, tolerates absent, and stays symmetric with encode.
+- Added `seal_vectors[1]` (two 33-byte compressed secp256k1 pins) to `tests/vectors/node-codec.json` and locked it byte-for-byte in both the Rust KAT and a new TS assertion block; `seal_vectors[0]` bytes unchanged.
+- Documented the additive field in `docs/METADATA_SCHEMAS.md` (§8 NodeWriteBody) plus a §3 Node version-history row, per METADATA_EVOLUTION_PROTOCOL §3.1.
+
+## Task Commits
+
+Executed TDD-style locally (RED fixture + failing KATs observed to fail; GREEN Rust; GREEN TS + docs) and landed as a single atomic commit per orchestrator constraint 6 (SUMMARY rides with the code):
+
+1. **Tasks 1-3 (RED→GREEN Rust→GREEN TS + docs + SUMMARY)** - see PLAN COMPLETE hash below (feat)
+
+_RED was confirmed before implementing: the extended Rust KAT failed to compile against the pin-unaware struct (`E0560: NodeWriteBody has no field recipient_pins`), and the placeholder `writeSealed` forced an assertion mismatch that produced the committed ciphertext value._
+
+## Files Created/Modified
+
+- `crates/core/src/node/types.rs` - `NodeWriteBody.recipient_pins` field + `base64_key_list` serde module
+- `crates/core/src/node/encode.rs` - round-trip tests: populated pins, empty-omission byte guard, tolerant-decode default
+- `crates/core/src/node/seal.rs` - `sample_write_body()` construction updated with `recipient_pins: vec![]` (blocking-compile fix)
+- `crates/core/tests/node_write_body_vectors.rs` - SealVector gains `recipient_pins`; loop decodes pins into the KAT write-body
+- `packages/core/src/node/types.ts` - optional `recipientPins?: string[]`
+- `packages/core/src/node/encode.ts` - conditional emission of `recipientPins` (only when non-empty)
+- `packages/core/src/node/decode.ts` - validate-when-present, attach-when-non-empty (symmetric with encode)
+- `packages/core/src/__tests__/node-codec-vectors.test.ts` - new `seal_vectors[1]` writeSealed assertion block
+- `tests/vectors/node-codec.json` - `seal_vectors[1]` non-empty-pin fixture (frozen `seal_vectors[0]` untouched)
+- `docs/METADATA_SCHEMAS.md` - `recipientPins` schema row + prose + Node version-history row
+
+## Decisions Made
+
+- **TS decode is symmetric with encode (attach `recipientPins` only when non-empty).** The plan text said "default absent/empty to `[]`", but unconditionally adding `recipientPins: []` broke the existing `folder node with writeBody seal→unseal` round-trip (`toEqual` treats `{recipientPins: []}` as unequal to a literal that omits the key). Omitting on empty preserves that test with zero test-literal edits, keeps encode/decode symmetric, and still satisfies the hard requirement "tolerate absent field, never throw" (Rust still yields an empty `Vec` via `#[serde(default)]`; the Rust-`[]`-vs-TS-`undefined` asymmetry is the accepted divergence called out in METADATA_EVOLUTION_PROTOCOL §6.2).
+- **Reused the fixed key/IV of `seal_vectors[0]`** for `seal_vectors[1]`; only the added `recipientPins` changes the plaintext, so the differing `writeSealed` directly demonstrates the pin bytes flow into the seal.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Updated `sample_write_body()` in seal.rs for the new required struct field**
+
+- **Found during:** Task 2 (Rust GREEN)
+- **Issue:** Adding `recipient_pins` to `NodeWriteBody` broke compilation of an existing helper in `crates/core/src/node/seal.rs` (`E0063: missing field recipient_pins`). Not listed in `files_modified`.
+- **Fix:** Added `recipient_pins: vec![]` to the `sample_write_body()` constructor.
+- **Files modified:** crates/core/src/node/seal.rs
+- **Verification:** `cargo test -p cipherbox-core --lib node` → 10 passed.
+- **Committed in:** part of the plan commit.
+
+---
+
+**Total deviations:** 1 auto-fixed (1 blocking). No scope creep — required for the crate to compile.
+
+## Issues Encountered
+
+- **Worktree had no installed dependencies / crypto dist.** `pnpm --filter @cipherbox/core test` failed resolving `@cipherbox/crypto`. Resolved by `pnpm install --frozen-lockfile` (workspace links present) + `pnpm --filter @cipherbox/crypto build` (dist was unbuilt). Blocking-environment setup, not a code change.
+
+## Notes / Verification
+
+- **Test pass counts:** Rust `node_write_body_vectors` = 1 passed; Rust lib `node` unit = 10 passed, 0 failed; TS `node-codec-vectors` = 24 passed; `pnpm --filter @cipherbox/core typecheck` = ok.
+- **cross_language.rs untouched:** `grep -c "node-codec.json" crates/crypto/tests/cross_language.rs` = 0 (it reads `crypto/node-aad.json`; its line-310 `seal_vectors.len() == 1` guard is a different oracle and stays green).
+- **Recovery-tool tolerance (D-03b no-op):** `grep -rn "writeKey|writeSealed|NodeWriteBody|recipientPins" apps/web/recovery-src/` returns a single COMMENT match (`main.ts:126`, "read-only — no writeKey argument"), not a parse. The plan AC expected literally zero matches; the intent (recovery tool never parses `NodeWriteBody`, so it tolerates the new field by construction) holds. Minor AC-literal vs actual mismatch, no behavior impact.
+- **No API/DB change:** client-side owner-sealed metadata field only; `pnpm api:generate` and migrations intentionally not run.
+
+## Next Phase Readiness
+
+- `NodeWriteBody.recipientPins` (both codecs) and `seal_vectors[1]` are available for the pin-issuance write (80-04) and the fail-closed enforcement consumers (80-06/07/08).
+
+---
+
+_Phase: 80-rotation-write-plane-and-re-mint-durability_
+_Completed: 2026-07-12_
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-PLAN.md
new file mode 100644
index 0000000000..70a23a900b
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-PLAN.md
@@ -0,0 +1,223 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 02
+type: tdd
+wave: 1
+depends_on: []
+files_modified:
+ - crates/fuse/src/write_ops/rotation_deps.rs
+ - crates/fuse/src/replay.rs
+autonomous: true
+requirements:
+ - "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation"
+ - "SC2-perf / D-02: cache GET /shares/sent once per rotation job instead of once per rotated node"
+user_setup: []
+
+must_haves:
+ truths:
+ - "A scope-exit read-key rotation republishes each locally-materialized rotated node with a populated write_sealed body, not None, via reconstruct_write_body from the InodeTable re-sealed under seal_node at the NEW generation (D-01, D-01a)"
+ - "Unit tests pin the reconstruction round-trip and the not-materialized None fallback (D-01c)"
+ - "replay.rs::recover_signing_seed recovers a rotated node's signing seed after rotation+remount — the 'no write_sealed body' fail path no longer fires for a materialized rotated node (D-01 durability)"
+ - "A scope-exit rotation over an N-node subtree issues <=1 GET /shares/sent, not N (D-02)"
+ artifacts:
+ - "crates/fuse/src/write_ops/rotation_deps.rs — reconstruct_write_body helper + ApiClientTransport publish wiring + job-scoped collect_sent_shares cache field"
+ - "crates/fuse/src/replay.rs — rotation-then-replay signing-seed-recovery regression test"
+ - "crates/fuse/src/write_ops/rotation_deps.rs — FakeTransportInner collect_sent_shares call-counter + reconstruction/None-fallback/cache tests"
+ key_links:
+ - "ApiClientTransport::publish reads write_key + ipns_private_key + child WriteChildRefs from the in-memory InodeTable and re-seals via seal_node at the node's NEW generation (ROLE_BODY 0x01 AAD)"
+ - "The cache lives on ApiClientTransport (constructed once per rotation job at grant_scope.rs:488) — needs interior mutability (RefCell/OnceCell) since the struct is borrowed immutably during the walk"
+ prohibitions:
+ - "MUST NOT hard-error when a node is NOT locally materialized — fail-open to write_sealed: None (D-01b), mirroring find_ipns_private_key's Option return"
+ - "MUST NOT rotate or mutate the write plane — only re-seal the UNCHANGED write body at the bumped generation (write-key rotation stays a Phase-72 concern) (D-01b)"
+ - "MUST NOT change the existing per-share 0x-strip/hex-decode key parsing or per-share error semantics in query_grants_rooted_at — only add caching (D-02)"
+ - "MUST NOT use a static/global cache — the cache is job-scoped on the transport instance (D-02, Don't Hand-Roll)"
+---
+
+
+Close the two mechanical Rust-FUSE rotation gaps that are independent of the D-03 pin work:
+
+- **D-01:** `ApiClientTransport::publish` currently republishes every rotated node with
+ `write_sealed: None` (the read-key rotation engine never populates it, and the FUSE adapter —
+ a Phase-72 deferral — never reconstructed it). This floods `list_folder_owned` with "owned child
+ has no write_sealed body" (607×/run observed) AND is a durability hole: `replay.rs` cannot recover
+ the node's signing seed after rotation+remount. Reconstruct the write body from the in-memory
+ `InodeTable` (the node's own stable write key + ipns_private_key + child WriteChildRefs rebuilt from
+ child inodes' write keys — all read-key-rotation-independent) and re-seal via `seal_node` at the
+ node's NEW generation. Fail-open to `None` for a non-materialized node.
+
+- **D-02:** `query_grants_rooted_at` calls `collect_sent_shares()` (a full `GET /shares/sent`) once per
+ rotated node → O(nodes × shares). Cache the result once per rotation job and filter by `root_node_id`.
+
+This plan does NOT touch the recipient-pin field (80-01/80-05) — the reconstruction here handles keys +
+children; pin preservation is added in 80-05 once the field exists.
+
+Purpose: restore owned-walkability and signing-seed durability after rotation, and drop O(nodes) network fan-out.
+Output: reconstruct-and-reseal write-body path, job-scoped sent-shares cache, and locked regression tests.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@crates/fuse/src/write_ops/rotation_deps.rs
+@crates/fuse/src/replay.rs
+@crates/fuse/src/inode.rs
+@crates/fuse/src/write_ops/grant_scope.rs
+@crates/core/src/node/seal.rs
+
+
+
+
+
+ Task 1: RED — reconstruction round-trip, None fallback, sent-shares call-count, and rotation-then-replay regression tests
+ crates/fuse/src/write_ops/rotation_deps.rs, crates/fuse/src/replay.rs
+
+ - crates/fuse/src/write_ops/rotation_deps.rs — `find_ipns_private_key` (lines 552-576) and `find_grant_root_state` (582-599) for the `inodes.inodes.values().find_map` lookup idiom; the existing `#[cfg(test)]` module and the `publish_count_for` FakeTransport call-count pattern (~lines 664-672 / 830-1270); `collect_sent_shares` (498-506) and `query_grants_rooted_at` (264-286, key parsing 270-278)
+ - crates/fuse/src/inode.rs — `InodeKind` (lines 119-172): Root/Folder/File each carry `read_key`, `write_key` (Zeroizing<[u8;32]>), `ipns_private_key` (Zeroizing>); `Inode.children: Option>` (line 252)
+ - crates/fuse/src/write_ops/grant_scope.rs (lines 485-521) — CONFIRM `FuseRotationDeps`/`ApiClientTransport` is constructed ONCE per rotation job at line 488 and `&deps` walks the whole subtree (this is why a transport-instance cache is job-scoped)
+ - crates/fuse/src/replay.rs — `recover_signing_seed` (~lines 261-297): the `published.write_sealed.as_ref().ok_or_else(...)` "no write_sealed body — cannot recover signing seed" fail path this proves closed
+ - crates/core/src/node/seal.rs (lines 48-74 `seal_node`/`unseal_node`, ROLE_BODY 0x01)
+
+
+ - Test A (reconstruct round-trip): given an InodeTable with a materialized Folder node (write_key, ipns_private_key, one child inode with its own write_key), `publish` produces a `write_sealed` that `unseal_node` under the node's write key at the NEW generation decodes back to a NodeWriteBody whose ipns_private_key and child WriteChildRef(s) match the inputs.
+ - Test B (None fallback): given a node NOT present in the InodeTable, `publish` yields `write_sealed: None` and returns Ok (no error).
+ - Test C (D-02 call-count): a rotation walk over N (>=3) rotated nodes calls the fake transport's `collect_sent_shares` at most once; `query_grants_rooted_at` still returns the correctly `root_node_id`-filtered grants per node.
+ - Test D (replay regression): after a rotation republish reconstructs write_sealed, `recover_signing_seed` on that PublishedNode succeeds (no "no write_sealed body" error).
+ - All four FAIL against current code (publish emits None; no cache; replay hits the fail path).
+
+
+ Add a `collect_sent_shares` call-counter to `FakeTransportInner` mirroring the existing
+ `publish_count_for` pattern. Author the four tests in the `#[cfg(test)]` module of rotation_deps.rs
+ (A/B/C) and a new rotation-then-replay test in replay.rs (D) that drives a reconstruction and then
+ calls `recover_signing_seed`. These tests define the contract; they must fail now. Do NOT implement
+ the reconstruction or cache yet.
+
+
+ cargo test -p cipherbox-fuse rotation_deps 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed"
+
+
+ - `grep -n "collect_sent_shares" crates/fuse/src/write_ops/rotation_deps.rs` shows a call-counter field on FakeTransportInner and an assertion of `<= 1`
+ - Tests A–D exist and FAIL against current code (proving non-vacuous RED)
+ - The replay.rs test references `recover_signing_seed` and asserts Ok after reconstruction
+
+ Four failing tests pin the reconstruction round-trip, None fallback, <=1 sent-shares fetch, and rotation-then-replay recovery.
+
+
+
+ Task 2: GREEN — reconstruct-and-reseal write body in ApiClientTransport::publish (D-01)
+ crates/fuse/src/write_ops/rotation_deps.rs
+
+ - crates/fuse/src/write_ops/rotation_deps.rs — `ApiClientTransport::publish` (~lines 417-496) and its read-plane sourcing/sequencing (create_ipns_record/upload_content ~434-463); the fail-closed precedent at 426-431
+ - crates/fuse/src/inode.rs — InodeKind variants and `children` list; the `apply_owned_children`/`InodeKind` match idiom
+ - crates/core/src/node/seal.rs — `seal_node` signature (writeKey, node_id, kind, generation, ROLE_BODY) and `WriteChildRef` shape from crates/core/src/node
+
+
+ - When `node.write_sealed` is None AND the node is materialized in the InodeTable: build a NodeWriteBody
+ from the node's own write_key + ipns_private_key + child WriteChildRefs (each child's `child_id` = child node_id, write key from the child inode), `encode_write_body`, seal via `seal_node` under the node's write key at the node's NEW generation, and set `write_sealed` to the result.
+ - When the node is NOT materialized: leave `write_sealed: None` and return Ok (D-01b).
+ - Child write keys are read-key-rotation-independent — do NOT re-derive or rotate them.
+
+
+ Add a `reconstruct_write_body(inodes, ipns_name, new_generation) -> Option>` helper following
+ the `find_ipns_private_key` `inodes.inodes.values().find_map` shape: locate the node by ipns_name, pull
+ its write_key + ipns_private_key, walk its `children` inode list to rebuild `WriteChildRef`s (child_id =
+ child.node_id, write key from the child inode), construct + `encode_write_body`, then `seal_node` at
+ `new_generation`. Return None when the node isn't found (fail-open). Wire it into `publish` so
+ `write_sealed` is populated only via this helper's Some result, preserving the existing read-plane
+ sequencing. Do NOT touch the write key material (no rotation). Key bytes are NEVER logged (CLAUDE.md).
+
+
+ cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8; cargo test -p cipherbox-fuse replay 2>&1 | tail -8
+
+
+ - `grep -n "reconstruct_write_body\|seal_node" crates/fuse/src/write_ops/rotation_deps.rs` shows the helper and its seal call at the node's new generation
+ - Tests A, B, D from Task 1 pass (`cargo test -p cipherbox-fuse rotation_deps` and `... replay` green)
+ - The helper returns None (not Err) for a non-materialized node — verified by Test B
+ - No write-key mutation: the reconstructed body's child write keys equal the input child inode write keys (Test A)
+
+ Rotation republish reconstructs a populated write_sealed for materialized nodes, fails open to None otherwise, and replay recovers the signing seed.
+
+
+
+ Task 3: GREEN — job-scoped collect_sent_shares cache (D-02)
+ crates/fuse/src/write_ops/rotation_deps.rs
+
+ - crates/fuse/src/write_ops/rotation_deps.rs — `ApiClientTransport` struct definition (~line 379, holds `api` + `&inodes` immutable borrow), `collect_sent_shares` (498-506), `query_grants_rooted_at` (264-286, filter at 268, per-share key parse/error at 270-278)
+ - crates/fuse/src/write_ops/grant_scope.rs (line 488) — confirms one transport instance per job
+
+
+ - `collect_sent_shares()` is invoked at most once across an entire rotation walk; subsequent
+ `query_grants_rooted_at` calls read the cached list and filter by `root_node_id`.
+ - Per-share 0x-strip/hex-decode and per-share RotateFailed error behavior are byte-for-byte unchanged.
+
+
+ Add an interior-mutable cache field to `ApiClientTransport` — a `RefCell
+
+ cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8
+
+
+ - Test C from Task 1 passes: a walk over >=3 nodes calls `collect_sent_shares` at most once
+ - `grep -n "RefCell\|OnceCell" crates/fuse/src/write_ops/rotation_deps.rs` shows the cache field on ApiClientTransport
+ - The per-share parsing block (0x strip + hex-decode + RotateFailed) is unchanged (diff shows only cache read/populate swapped for the fresh fetch)
+ - `cargo test -p cipherbox-fuse` passes with no regressions
+
+ A rotation job fetches /shares/sent at most once; grant filtering and error semantics are unchanged.
+
+
+
+
+
+- `reconstruct_write_body(inodes, ipns_name, new_generation) -> Option>` (rotation_deps.rs) — extended in 80-05 to also carry recipient_pins
+- Job-scoped sent-shares cache field on `ApiClientTransport` (rotation_deps.rs)
+- `FakeTransportInner` `collect_sent_shares` call-counter (test infra reused by 80-06)
+- replay.rs rotation-then-replay signing-seed-recovery regression test
+- NOTE for 80-05: this reconstruction handles keys+children only; recipient_pins preservation is added in 80-05 once the D-03b field lands (NOT a scope reduction of D-01 — pins are a D-03 concern)
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| FUSE mount (in-memory InodeTable) → IPFS republish | Reconstructed write-body assembled from local plaintext key material |
+| owner device → CipherBox API (/shares/sent) | Repeated relay fetches during a rotation walk |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-04 | Repudiation | replay.rs recover_signing_seed | high | mitigate | Reconstruct write_sealed so the owner retains the signing seed after rotation+remount (D-01) |
+| T-80-05 | Denial of Service | list_folder_owned owned-walk | high | mitigate | Populated write_sealed removes the 607×/run "no write_sealed body" flood (D-01) |
+| T-80-06 | Denial of Service | GET /shares/sent fan-out | medium | mitigate | Job-scoped cache bounds fetches to <=1 per rotation (D-02) |
+| T-80-07 | Information Disclosure | reconstructed key material in logs | medium | mitigate | Key bytes never logged; only public ipns_name/child_id logged (CLAUDE.md rule 2) |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `cargo test -p cipherbox-fuse` green (rotation_deps reconstruction + None fallback + cache call-count; replay rotation-then-replay recovery)
+- No write-key mutation introduced (reconstruction reseals the unchanged write plane at the new generation)
+- Per-share parsing/error semantics in query_grants_rooted_at unchanged
+
+
+
+Scope-exit rotation republishes a populated write_sealed for every materialized rotated node (owned-walk
+and replay signing-seed recovery survive), fails open to None for non-materialized nodes, and fetches
+/shares/sent at most once per rotation job — all locked by regression tests.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md
new file mode 100644
index 0000000000..8caea5da51
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md
@@ -0,0 +1,165 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 02
+subsystem: infra
+tags: [rust, fuse, rotation, ipns, node-v3, write-body, seal, aead, caching]
+
+# Dependency graph
+requires:
+ - phase: 80-01
+ provides: NodeWriteBody.recipient_pins field on the Rust/TS node codec
+provides:
+ - reconstruct_write_body helper — rebuilds a rotated node's write-body from the in-memory InodeTable and re-seals it under the node's own write key at the NEW generation (ROLE_BODY 0x01)
+ - ApiClientTransport::publish now injects a populated write_sealed for materialized rotated nodes (was always None), restoring owned-walkability and replay signing-seed durability
+ - Job-scoped GET /shares/sent cache on FuseRotationDeps (<=1 fetch per rotation job)
+ - FakeTransportInner collect_sent_shares call-counter test infra (reused by 80-06)
+ - replay.rs rotation-then-replay signing-seed-recovery regression test
+affects: [80-05, 80-06]
+
+# Tech tracking
+tech-stack:
+ added: [tokio::sync::OnceCell]
+ patterns:
+ - "Reconstruct-and-reseal: rebuild a node's write plane from local InodeTable key material (read-key-rotation-independent) and re-seal at the node's new generation, never mutating the write plane"
+ - "Job-scoped interior-mutable cache (OnceCell) on the once-per-job FuseRotationDeps to fetch-once/reuse across an immutable-borrow walk"
+
+key-files:
+ created:
+ - .planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md
+ modified:
+ - crates/fuse/src/write_ops/rotation_deps.rs
+ - crates/fuse/src/replay.rs
+ - crates/sdk/src/emit.rs
+ - crates/sdk/src/listing.rs
+ - crates/fuse/src/content_ops.rs
+ - crates/fuse/src/journal_helpers.rs
+ - crates/fuse/src/fs.rs
+ - crates/fuse/src/write_ops/implementation/delete.rs
+ - crates/fuse/src/platform/windows/write_ops.rs
+
+key-decisions:
+ - "Cache lives on FuseRotationDeps (generic over T), not ApiClientTransport as the plan text stated — Test C's locked contract (the FAKE transport's collect_sent_shares called <=1) can only be satisfied by caching at the layer that wraps transport.collect_sent_shares(). FuseRotationDeps is the once-per-job instance, so it is still job-scoped, not static/global."
+ - "OnceCell (not RefCell) for the cache because query_grants_rooted_at is async — get_or_try_init never holds a borrow across .await and keeps the future Send."
+ - "Child WriteChildRef.write_key_sealed is sealed at AAD generation 0, matching the established build_folder_metadata / build_child_refs write-splice convention (child write plane is not rotated here); only the node's own write-body ROLE_BODY seal uses the new generation."
+ - "recipient_pins emitted empty by reconstruct_write_body — pin preservation (D-03b) is 80-05's concern once the field is populated on the inode; this reconstruction handles keys + children only."
+
+patterns-established:
+ - "reconstruct-and-reseal write body from InodeTable at a new generation, fail-open to None for non-materialized nodes"
+ - "job-scoped OnceCell cache for a per-node fan-out relay fetch"
+
+requirements-completed:
+ - "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation"
+ - "SC2-perf / D-02: cache GET /shares/sent once per rotation job instead of once per rotated node"
+
+coverage:
+ - id: D1
+ description: "Rotation republish reconstructs a populated write_sealed for a materialized rotated node (D-01): write-body carries the node's own signing seed + child WriteChildRefs, re-sealed under the node's own write key at the new generation."
+ requirement: "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_round_trips_ipns_key_and_child_write_refs"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "reconstruct_write_body fails open to None (never Err/panic) for a node not locally materialized (D-01b)."
+ requirement: "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_fails_open_to_none_for_a_non_materialized_node"
+ status: pass
+ human_judgment: false
+ - id: D3
+ description: "replay.rs::recover_signing_seed recovers a rotated node's signing seed from the reconstructed write_sealed — the 'no write_sealed body' fail path no longer fires after rotation+remount (T-80-04)."
+ requirement: "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/replay.rs#rotation_reconstructed_write_sealed_recovers_signing_seed"
+ status: pass
+ human_judgment: false
+ - id: D4
+ description: "A rotation walk over N (>=3) nodes fetches GET /shares/sent at most once, with root_node_id filtering + per-share parsing unchanged (D-02, T-80-06)."
+ requirement: "SC2-perf / D-02: cache GET /shares/sent once per rotation job instead of once per rotated node"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#rotation_walk_fetches_sent_shares_at_most_once"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 45min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 02: Rotation write-plane reconstruction + sent-shares cache Summary
+
+**Rotation republish reconstructs a populated write_sealed from the in-memory InodeTable (restoring owned-walkability and replay signing-seed durability) and caches GET /shares/sent to at most one fetch per rotation job.**
+
+## Performance
+
+- **Duration:** ~45 min
+- **Tasks:** 3 (TDD: RED → GREEN → GREEN)
+- **Files modified:** 9 (2 in-scope + 7 Rule-3 compile-unblock)
+
+## Accomplishments
+- **D-01:** `ApiClientTransport::publish` now reconstructs the write-body from the locally-materialized `InodeTable` (own write key + `ipns_private_key` + child `WriteChildRef`s copied verbatim from child inodes) and re-seals it via `seal_node` under the node's own write key at its NEW generation, injecting a populated `write_sealed` where the rotation engine emitted `None`. Fails open to `None` for a non-materialized node; never rotates/mutates the write plane. This removes the `list_folder_owned` "no write_sealed body" flood (T-80-05) and closes the `replay.rs::recover_signing_seed` durability hole (T-80-04).
+- **D-02:** Added a job-scoped `tokio::sync::OnceCell` cache on `FuseRotationDeps` so a rotation walk fetches `GET /shares/sent` at most once instead of once per rotated node — per-share `root_node_id` filter and 0x-strip/hex-decode/error parsing are byte-for-byte unchanged.
+- Locked all four contracts with regression tests (reconstruct round-trip, None fallback, <=1 sent-shares fetch, rotation-then-replay recovery).
+
+## Task Commits
+
+1. **Task 1: RED tests + compile-unblock** - `eaed02937` (test)
+2. **Task 2: GREEN — reconstruct-and-reseal write body (D-01)** - `5c1ee8409` (feat)
+3. **Task 3: GREEN — job-scoped sent-shares cache (D-02) + SUMMARY** - this commit (perf)
+
+## Files Created/Modified
+- `crates/fuse/src/write_ops/rotation_deps.rs` - `reconstruct_write_body` helper, `publish` wiring, job-scoped `OnceCell` sent-shares cache, `FakeTransportInner` call-counter, tests A/B/C
+- `crates/fuse/src/replay.rs` - rotation-then-replay signing-seed-recovery regression test (Test D) + Rule-3 constructor fixes
+- `crates/sdk/src/emit.rs`, `crates/sdk/src/listing.rs`, `crates/fuse/src/content_ops.rs`, `crates/fuse/src/journal_helpers.rs`, `crates/fuse/src/fs.rs`, `crates/fuse/src/write_ops/implementation/delete.rs`, `crates/fuse/src/platform/windows/write_ops.rs` - Rule-3 compile-unblock (`recipient_pins: Vec::new()` in downstream `NodeWriteBody` constructors)
+
+## Decisions Made
+- **Cache placement:** The plan text said "cache on `ApiClientTransport`", but Test C (the locked acceptance contract) asserts the FAKE transport's `collect_sent_shares` is called `<= 1`. That can only hold if caching happens at the layer wrapping `transport.collect_sent_shares()` — i.e. `FuseRotationDeps::query_grants_rooted_at`. `FuseRotationDeps` is the once-per-job instance (grant_scope.rs:488), so the cache remains job-scoped and instance-local, satisfying the "not static/global" prohibition. Bonus: no construction-site literal changes at grant_scope.rs:489 (the field is initialized inside `new()`).
+- **`OnceCell` over `RefCell`:** the fetch is async; `get_or_try_init` holds no borrow across `.await`, keeping the deps future `Send` (a `RefCell` field would break `Send` for the spawned rotation walk).
+- **Child splice generation 0:** child `WriteChildRef.write_key_sealed` is sealed at AAD generation `0`, matching the existing `build_folder_metadata` / `build_child_refs` convention. The node's own write-body ROLE_BODY seal uses the node's NEW generation (this is what `recover_signing_seed` rebuilds). The child write plane is not rotated here.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Downstream NodeWriteBody constructors left non-compiling by 80-01**
+- **Found during:** Task 1 (initial `cargo check`)
+- **Issue:** Plan 80-01 added the required `recipient_pins` field to `NodeWriteBody` but only fixed its own core-crate constructor. Every downstream `NodeWriteBody` constructor in `crates/sdk` and `crates/fuse` failed to compile (`E0063: missing field recipient_pins`), so the whole workspace — including the `cargo test -p cipherbox-fuse` target this plan must run — would not build. No sibling 80-x plan lists these constructors in its `files_modified`.
+- **Fix:** Added `recipient_pins: Vec::new()` to all 11 downstream constructors across 7 files. Byte-identical wire behavior (the field is `skip_serializing_if = "Vec::is_empty"`, so an empty list is omitted). This mirrors the same "blocking-compile fix" deviation 80-01 itself applied to `crates/core`.
+- **Files modified:** crates/sdk/src/emit.rs, crates/sdk/src/listing.rs, crates/fuse/src/content_ops.rs, crates/fuse/src/journal_helpers.rs, crates/fuse/src/fs.rs, crates/fuse/src/write_ops/implementation/delete.rs, crates/fuse/src/platform/windows/write_ops.rs, crates/fuse/src/replay.rs (2 constructors)
+- **Verification:** `cargo check -p cipherbox-fuse --features fuse` and `cargo check -p cipherbox-sdk` clean; full `cargo test -p cipherbox-fuse` green (124 passed).
+- **Committed in:** `eaed02937` (Task 1) for 7 files; the two `replay.rs` constructor fixes rode the same commit as replay Test D.
+
+**2. [Plan-text divergence] Cache on FuseRotationDeps, not ApiClientTransport**
+- **Found during:** Task 3
+- **Issue:** The plan's `key_links`/action placed the cache on `ApiClientTransport`, but the locked Test C exercises the `FakeTransport` path and asserts its `collect_sent_shares` is called `<= 1`.
+- **Fix:** Implemented the cache on the generic `FuseRotationDeps` (the once-per-job instance wrapping either transport) so both the production and fake paths fetch-once/reuse. Still job-scoped and interior-mutable (`OnceCell`); satisfies every prohibition (no static/global). Grep-check for `RefCell|OnceCell` in rotation_deps.rs is satisfied.
+- **Verification:** Test C passes; full fuse suite green.
+- **Committed in:** this commit (Task 3).
+
+---
+
+**Total deviations:** 2 (1 Rule-3 blocking compile-unblock; 1 plan-text divergence forced by the locked acceptance test).
+**Impact on plan:** The compile-unblock was mandatory for the plan's own tests to build (same class of fix 80-01 applied). The cache-placement divergence keeps the behavior identical and the prohibitions intact. No scope creep beyond the unavoidable compile-unblock.
+
+## Issues Encountered
+- The workspace did not compile at plan start (see Deviation 1). Resolved by the Rule-3 compile-unblock before RED.
+
+## Known Gaps / Notes for 80-05
+- `reconstruct_write_body` emits an empty `recipient_pins` list. Pin preservation (D-03b) is 80-05's job once the pins are cached on the inode — this is a planned handoff, NOT a scope reduction of D-01 (the plan's own note).
+- `replay.rs::fetch_splice_publish_parent` re-seals the parent write-body with `recipient_pins: Vec::new()` (it decodes only `write_children` from the parent's current write-body). If a rotated/shared parent carries pins, a replay re-splice would drop them. This is out of scope for 80-02 (which owns rotation republish + the replay regression test, not replay pin preservation) and no 80-x plan currently lists `replay.rs` for pin work — flagged here for triage.
+
+## User Setup Required
+None - no external service configuration required.
+
+## Next Phase Readiness
+- 80-05 (wave 2, depends on 80-02) can extend `reconstruct_write_body` to thread cached `recipient_pins`; the helper signature and seal path are in place.
+- 80-06 (wave 3) can reuse the `FakeTransportInner` call-counter test infra.
+
+---
+*Phase: 80-rotation-write-plane-and-re-mint-durability*
+*Completed: 2026-07-12*
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-PLAN.md
new file mode 100644
index 0000000000..7acbf0eb01
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-PLAN.md
@@ -0,0 +1,177 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 03
+type: tdd
+wave: 1
+depends_on: []
+files_modified:
+ - packages/sdk-core/src/rotation/engine.ts
+ - packages/sdk-core/src/__tests__/rotation/engine.test.ts
+ - packages/sdk/src/share/owner-reconcile.ts
+ - packages/sdk/src/__tests__/owner-reconcile.test.ts
+autonomous: true
+requirements:
+ - "SC3 / D-04: TS rotatedNodes stores a defensive 32-byte copy of readKey (no aliasing with parentNewReadKey), matching Rust parity"
+ - "SC2-perf / D-02 (TS mirror): queryGrantsFn caches listSentGrants() across calls within one reconcile pass"
+user_setup: []
+
+must_haves:
+ truths:
+ - "Every rotatedNodes entry's readKey is an independent 32-byte copy, non-aliased with the corresponding parentNewReadKey (D-04)"
+ - "A rotatedNodes readKey equals the node's expected new key after rotateReadFromNode, and is non-zero (D-04)"
+ - "queryGrantsFn fetches listSentGrants() at most once per runOwnerReconcile pass, filtering by rootNodeId per call (D-02 TS)"
+ artifacts:
+ - "packages/sdk-core/src/rotation/engine.ts — new Uint8Array(...) defensive copy at every rotatedNodes.set readKey"
+ - "packages/sdk-core/src/__tests__/rotation/engine.test.ts — non-aliasing/non-zero/correct-value regression test"
+ - "packages/sdk/src/share/owner-reconcile.ts — closure-scoped listSentGrants cache in buildGrantRemintCallbacks"
+ - "packages/sdk/src/__tests__/owner-reconcile.test.ts — single-fetch cache assertion"
+ key_links:
+ - "The defensive copy is applied at the rotatedNodes.set() collection boundary ONLY — parentNewReadKey stays the live reference the walk uses to seal children (D-04, Pattern 4)"
+ - "Rust already clones each key into Zeroizing<[u8;32]> — this is the TS parity fix; no Rust change"
+ prohibitions:
+ - "MUST NOT copy or alter parentNewReadKey / parentOldReadKey — only the rotatedNodes.set readKey value gets the defensive copy (D-04)"
+ - "MUST NOT change the Rust rotation engine — Rust is already correct (D-04)"
+ - "MUST NOT introduce a global/static cache for listSentGrants — the cache is scoped to a single reconcile pass (D-02)"
+---
+
+
+Two independent TS-side mechanical mirrors of the mechanical items, both wave-1 and pin-independent:
+
+- **D-04 (SC3):** The TS rotation engine stores the SAME `Uint8Array` reference in `rotatedNodes` that
+ `ParentTrackingState.parentNewReadKey` also holds (root branch and BFS child branch). Not a live bug
+ today (`parentNewReadKey` is never zeroed), but a natural future D-09 zeroization tightening would
+ silently zero the returned `rotatedNodes` entry → the Rust FUSE consumer
+ (`grant_scope.rs::refresh_rotated_inode_read_keys`) would refresh an inode read key to all-zeros →
+ mis-decryption / data loss. Rust already clones each key into `Zeroizing<[u8;32]>`. Fix: store a
+ defensive 32-byte copy (`new Uint8Array(...)`) at each `rotatedNodes.set()` call, leaving
+ `parentNewReadKey` untouched (the live reference the walk uses to seal children).
+
+- **D-02 (TS mirror, SC2-perf):** `queryGrantsFn` calls `transport.listSentGrants()` fresh on every
+ invocation → the TS mirror of the O(nodes × shares) fetch. Cache the result for the lifetime of a
+ single `runOwnerReconcile` pass, filtering by `rootNodeId` per call.
+
+Purpose: TS/Rust parity for rotatedNodes key ownership + single-fetch sent-shares in the TS reconcile path.
+Output: defensive-copy fix + non-aliasing regression test; closure-scoped listSentGrants cache + single-fetch test.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@packages/sdk-core/src/rotation/engine.ts
+@packages/sdk/src/share/owner-reconcile.ts
+@packages/sdk/src/__tests__/owner-reconcile.test.ts
+
+
+
+
+
+ Task 1: RED+GREEN — defensive 32-byte copy at every rotatedNodes.set readKey (D-04)
+ packages/sdk-core/src/rotation/engine.ts, packages/sdk-core/src/__tests__/rotation/engine.test.ts
+
+ - packages/sdk-core/src/rotation/engine.ts — `RotatedNodeKey` type (lines 345-350); the root-branch `rotatedNodes.set(rootNodeIpnsName, { readKey: rootResult.childReadKey, ... })` (~line 2055) and its sibling `parentNewReadKey: rootResult.childReadKey` (~:2066); the BFS child-branch `rotatedNodes.set(item.childRef.ipnsName, { readKey: result.childReadKey, ... })` (~:2226) and `parentNewReadKey: result.childReadKey` (~:2287); the existing defensive-copy idiom + comment at `parentOldReadKey: new Uint8Array(rootReadKey)` (~:2068). ALSO grep ALL `rotatedNodes.set(` sites (there is a third at ~:1815 in the resume/repair path) — apply the fix at every site whose readKey aliases a parentNewReadKey.
+ - packages/sdk-core/src/__tests__/rotation/engine.test.ts — existing rotateReadFromNode test setup to extend
+
+
+ - After `rotateReadFromNode`, for every entry in the returned `rotatedNodes` map: `entry.readKey` is NOT the same object as the corresponding `parentNewReadKey`, is a 32-byte non-zero array, and equals the node's expected post-rotation read key.
+ - A test that (RED) mutates/zeros a `parentNewReadKey` reference must NOT affect the returned `rotatedNodes` entry (proves non-aliasing). Before the fix this assertion fails; after, it passes.
+
+
+ Change each `rotatedNodes.set(...)` readKey value to `new Uint8Array()` — root:
+ `new Uint8Array(rootResult.childReadKey)`, child: `new Uint8Array(result.childReadKey)`, and any third
+ site found by grep. Leave every `parentNewReadKey: ...childReadKey` assignment untouched. Mirror the
+ exact "defensive copy owned by this collection, safe from a future zero-on-drop" comment style already
+ used at the `parentOldReadKey` idiom. Add a regression test in engine.test.ts asserting for each
+ rotatedNodes entry: non-aliased with parentNewReadKey (mutate-parent-does-not-affect-entry), non-zero,
+ and equal to the expected new key.
+
+
+ pnpm --filter @cipherbox/sdk-core test rotation/engine 2>&1 | tail -15
+
+
+ - `grep -n "new Uint8Array(rootResult.childReadKey)\|new Uint8Array(result.childReadKey)" packages/sdk-core/src/rotation/engine.ts` shows the defensive copies at the rotatedNodes.set sites
+ - Every `rotatedNodes.set(` site identified by grep uses `new Uint8Array(...)` for readKey; no `parentNewReadKey` assignment was changed
+ - The new regression test asserts non-aliasing (mutating a parentNewReadKey does not alter the returned entry), non-zero, and correct value — and passes
+ - `pnpm --filter @cipherbox/sdk-core test rotation/engine` green
+
+ rotatedNodes readKeys are independent non-zero copies matching Rust's Zeroizing-clone parity; parentNewReadKey references are untouched.
+
+
+
+ Task 2: RED+GREEN — cache listSentGrants() per reconcile pass in buildGrantRemintCallbacks (D-02 TS)
+ packages/sdk/src/share/owner-reconcile.ts, packages/sdk/src/__tests__/owner-reconcile.test.ts
+
+ - packages/sdk/src/share/owner-reconcile.ts — `buildGrantRemintCallbacks` (lines 66-84); `queryGrantsFn` calls `transport.listSentGrants()` fresh (line 71) and filters by rootNodeId (line 73); `runOwnerReconcile` (94-104)
+ - packages/sdk/src/__tests__/owner-reconcile.test.ts — existing transport mock/spy to extend with a call-count assertion
+
+
+ - Across repeated `queryGrantsFn(nodeId)` calls within one `buildGrantRemintCallbacks`/`runOwnerReconcile` pass, `transport.listSentGrants()` is invoked at most once; each call still returns the rootNodeId-filtered grant subset.
+ - RED: a test spying on `listSentGrants` and invoking `queryGrantsFn` N times expects call-count 1; fails before the cache.
+
+
+ In `buildGrantRemintCallbacks`, introduce a closure-scoped memo — `let cached: Promise | undefined`
+ (or the returned-row type) — populated on first `queryGrantsFn` call and reused thereafter. Keep the
+ existing filter-by-rootNodeId logic unchanged (line 73). Add an owner-reconcile.test.ts case asserting
+ `listSentGrants` is called at most once across multiple queryGrantsFn invocations while filtered results
+ stay correct.
+
+
+ pnpm --filter @cipherbox/sdk test owner-reconcile 2>&1 | tail -12
+
+
+ - `grep -n "cached\|listSentGrants" packages/sdk/src/share/owner-reconcile.ts` shows a closure-scoped memo wrapping listSentGrants
+ - The new test asserts `listSentGrants` call-count <= 1 across multiple queryGrantsFn calls and correct rootNodeId filtering
+ - No global/module-level cache introduced (the memo lives inside buildGrantRemintCallbacks)
+ - `pnpm --filter @cipherbox/sdk test owner-reconcile` green
+
+ queryGrantsFn fetches sent grants once per reconcile pass with unchanged per-node filtering.
+
+
+
+
+
+- Defensive-copy `new Uint8Array(...)` at every rotatedNodes.set readKey (engine.ts)
+- Non-aliasing/non-zero/correct-value regression test (engine.test.ts)
+- Closure-scoped listSentGrants memo in buildGrantRemintCallbacks (owner-reconcile.ts) — 80-07 later adds getPinsFn to the same callbacks builder (sequential, same file)
+- Single-fetch cache assertion (owner-reconcile.test.ts)
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| rotation engine → FUSE consumer (via returned rotatedNodes map) | Returned key ownership must survive a future zeroization tightening |
+| owner device → CipherBox API (listSentGrants) | Repeated relay fetches during a reconcile pass |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-08 | Tampering (self-inflicted) | rotatedNodes readKey aliasing | medium | mitigate | Defensive 32-byte copy prevents a future zero-on-drop from zeroing returned keys → no all-zeros inode refresh (D-04) |
+| T-80-09 | Denial of Service | listSentGrants fan-out | low | mitigate | Per-pass memo bounds fetches to <=1 (D-02 TS) |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `pnpm --filter @cipherbox/sdk-core test rotation/engine` green (non-aliasing/non-zero/correct-value)
+- `pnpm --filter @cipherbox/sdk test owner-reconcile` green (single-fetch)
+- No Rust change; no parentNewReadKey/parentOldReadKey change
+
+
+
+Every returned rotatedNodes readKey is an independent, non-zero 32-byte copy equal to the expected new key
+(TS/Rust parity), and the TS owner-reconcile path fetches sent grants at most once per pass.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md
new file mode 100644
index 0000000000..8443fb6456
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md
@@ -0,0 +1,126 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 03
+subsystem: testing
+tags: [rotation, zeroization, ts-rust-parity, owner-reconcile, memoization]
+
+# Dependency graph
+requires:
+ - phase: 74-rotation-deep-scope-exit
+ provides: RotateReadResult.rotatedNodes deep-tree key surfacing (SC1) that this hardens
+provides:
+ - "TS rotation engine stores a defensive 32-byte copy of every rotatedNodes readKey (non-aliased with parentNewReadKey) — Rust Zeroizing-clone parity (D-04)"
+ - "buildGrantRemintCallbacks memoizes listSentGrants() per reconcile pass — bounds the O(nodes × shares) fan-out to <=1 fetch (D-02 TS mirror)"
+affects: [80-07-owner-reconcile-getPinsFn, rotation, fuse-inode-refresh]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Defensive Uint8Array copy at the collection boundary (rotatedNodes.set) while the live parentNewReadKey reference is left untouched for the seal walk"
+ - "Closure-scoped promise memo inside a callbacks-builder factory (per-pass cache, never global/static)"
+
+key-files:
+ created: []
+ modified:
+ - packages/sdk-core/src/rotation/engine.ts
+ - packages/sdk-core/src/__tests__/rotation/engine.test.ts
+ - packages/sdk/src/share/owner-reconcile.ts
+ - packages/sdk/src/__tests__/owner-reconcile.test.ts
+
+key-decisions:
+ - "Defensive copy applied at the rotatedNodes.set() readKey ONLY; parentNewReadKey/parentOldReadKey left as live references (D-04, Pattern 4)"
+ - "Cache is a closure-scoped `let cachedGrants: Promise` populated via `??=` — scoped to one buildGrantRemintCallbacks bundle, verified to re-fetch on a fresh bundle"
+
+patterns-established:
+ - "Pattern 4 (rotatedNodes ownership): the returned map owns independent key copies so a future zero-on-drop of parentNewReadKey cannot corrupt consumer-visible keys"
+
+requirements-completed:
+ - "SC3 / D-04: TS rotatedNodes stores a defensive 32-byte copy of readKey (no aliasing with parentNewReadKey), matching Rust parity"
+ - "SC2-perf / D-02 (TS mirror): queryGrantsFn caches listSentGrants() across calls within one reconcile pass"
+
+coverage:
+ - id: D1
+ description: "TS rotation engine stores a non-aliased, non-zero 32-byte copy of every rotatedNodes readKey (root, BFS child, dirty-resume repair), matching Rust's Zeroizing-clone (D-04)"
+ requirement: "SC3 / D-04: TS rotatedNodes stores a defensive 32-byte copy of readKey (no aliasing with parentNewReadKey), matching Rust parity"
+ verification:
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/rotation/engine.test.ts#D-04: each rotatedNodes readKey is a non-aliased, non-zero copy (mutating parentNewReadKey does not affect the entry)"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "buildGrantRemintCallbacks caches listSentGrants() per reconcile pass — <=1 fetch across multiple queryGrantsFn calls, closure-scoped (not global), per-node rootNodeId filtering unchanged (D-02 TS)"
+ requirement: "SC2-perf / D-02 (TS mirror): queryGrantsFn caches listSentGrants() across calls within one reconcile pass"
+ verification:
+ - kind: unit
+ ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 1b: listSentGrants is fetched at most once across multiple queryGrantsFn calls, filtering stays correct per node"
+ status: pass
+ - kind: unit
+ ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 1c: the cache is scoped per buildGrantRemintCallbacks call — a fresh callbacks bundle re-fetches (no global/static cache)"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 15min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 03: Rotation-key ownership + sent-grants memo Summary
+
+**TS rotation engine now stores non-aliased 32-byte defensive copies of every rotatedNodes readKey (Rust Zeroizing-clone parity, D-04), and the owner-reconcile driver memoizes listSentGrants() per pass to bound the fan-out to a single fetch (D-02 TS).**
+
+## Performance
+
+- **Duration:** ~15 min
+- **Started:** 2026-07-12T20:00:00Z
+- **Completed:** 2026-07-12T20:05:00Z
+- **Tasks:** 2 (both TDD)
+- **Files modified:** 4
+
+## Accomplishments
+
+- Applied `new Uint8Array(...)` defensive copy at both aliasing `rotatedNodes.set()` sites — root branch (`rootResult.childReadKey`) and BFS child branch (`result.childReadKey`). The third site (dirty-resume repair, ~:1817) already copied `readKeyPrime`, so all three now own independent buffers. `parentNewReadKey`/`parentOldReadKey` left untouched.
+- Added an engine regression test proving each rotatedNodes readKey is a distinct object from `result.readKey` (the retained parentNewReadKey alias), is non-zero, equals the correct new key, and survives a simulated zero-on-drop of the parent reference.
+- Introduced a closure-scoped `cachedGrants` promise memo in `buildGrantRemintCallbacks` (`??=` populate-once), leaving the per-node `rootNodeId` filter unchanged.
+- Added two owner-reconcile tests: single-fetch across multiple `queryGrantsFn` calls with correct per-node filtering, and a fresh-bundle-re-fetches test proving the cache is not global/static.
+
+## Task Commits
+
+Committed as a single commit per execution constraint (code + tests + SUMMARY together):
+
+1. **Task 1: defensive 32-byte copy at every rotatedNodes.set readKey (D-04)** — engine.ts + engine.test.ts
+2. **Task 2: cache listSentGrants() per reconcile pass (D-02 TS)** — owner-reconcile.ts + owner-reconcile.test.ts
+
+## Files Created/Modified
+
+- `packages/sdk-core/src/rotation/engine.ts` — defensive `new Uint8Array(...)` copy at root (:2060) and BFS child (:2234) rotatedNodes.set readKey sites
+- `packages/sdk-core/src/__tests__/rotation/engine.test.ts` — D-04 non-aliasing/non-zero/correct-value regression test
+- `packages/sdk/src/share/owner-reconcile.ts` — closure-scoped `cachedGrants` memo wrapping `transport.listSentGrants()`
+- `packages/sdk/src/__tests__/owner-reconcile.test.ts` — single-fetch cache assertion + per-pass-scope assertion
+
+## Decisions Made
+
+- Defensive copy at the collection boundary only; the live `parentNewReadKey` reference the walk uses to seal children is deliberately left aliasing `childReadKey` (matches the plan's Pattern 4 and Rust's structure). No Rust change — Rust already clones into `Zeroizing<[u8;32]>`.
+- Memo implemented with `let cachedGrants: Promise | undefined` + `??=`, caching the promise (not the awaited value) so concurrent first-callers share one in-flight fetch.
+
+## Deviations from Plan
+
+None - plan executed exactly as written. (The dirty-resume `rotatedNodes.set` at ~:1817 flagged by the plan's grep instruction already used a `new Uint8Array(readKeyPrime)` defensive copy and required no change.)
+
+## Issues Encountered
+
+- Scoped test runs initially failed with vite `Failed to resolve entry for package "@cipherbox/core"` / `@cipherbox/api-client` — stale/absent workspace dists. Resolved as setup by building `@cipherbox/core`, `@cipherbox/api-client`, `@cipherbox/crypto`, `@cipherbox/sdk-core` (dist-staleness only; no code impact).
+- Prettier flagged one wrapping in the new engine test assertion; fixed via `prettier --write` and re-verified with eslint + a re-run of the test suite.
+
+## User Setup Required
+
+None - no external service configuration required.
+
+## Next Phase Readiness
+
+- 80-07 will add `getPinsFn` to the same `buildGrantRemintCallbacks` builder (sequential, same file) — the memo pattern is now established there for it to extend.
+
+---
+*Phase: 80-rotation-write-plane-and-re-mint-durability*
+*Completed: 2026-07-12*
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-PLAN.md
new file mode 100644
index 0000000000..ededd33ed3
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-PLAN.md
@@ -0,0 +1,224 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 04
+type: tdd
+wave: 2
+depends_on: ["80-01"]
+files_modified:
+ - packages/sdk-core/src/share/recipient-pins.ts
+ - packages/sdk-core/src/share/index.ts
+ - packages/sdk-core/src/folder/registration.ts
+ - packages/sdk-core/src/__tests__/share/recipient-pins.test.ts
+ - packages/sdk/src/client.ts
+autonomous: true
+requirements:
+ - "SC2 / D-03a: store the issuance-time recipient pubkey in the shared root node's owner-sealed NodeWriteBody (server-opaque, cross-device)"
+ - "SC2 / D-03c: at grant creation, append the pasted recipient pubkey to the node's write-body pin list and republish"
+user_setup: []
+
+must_haves:
+ truths:
+ - "A pin written via addRecipientPubkeyPin round-trips: getRecipientPubkeyPins on the republished node returns the appended recipient pubkey (D-03a/c)"
+ - "updateFolderMetadataAndPublish preserves existing recipientPins across any folder-metadata update and across a CAS-409 merge (pins are never silently dropped) (D-03a durability)"
+ - "assertRecipientPinned throws when the recipient is absent from the pin list AND when the pin list is empty/absent (D-03e no-legacy hard fail), and returns normally on a match"
+ artifacts:
+ - "packages/sdk-core/src/share/recipient-pins.ts — assertRecipientPinned, extractRecipientPins, appendRecipientPin (pure helpers)"
+ - "packages/sdk-core/src/folder/registration.ts — recipientPins threaded through updateFolderMetadataAndPublish seal + CAS-merge"
+ - "packages/sdk/src/client.ts — addRecipientPubkeyPin(itemIpnsName, recipientPublicKey) + getRecipientPubkeyPins(itemIpnsName) wrappers"
+ - "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts — write→read round-trip + assert-or-throw + merge-preservation tests"
+ key_links:
+ - "addRecipientPubkeyPin resolves the node, unseals its current write-body (writeKey), appends the pin (dedup), re-seals via sealNode and CAS-republishes via updateFolderMetadataAndPublish — generation UNCHANGED, sequenceNumber increments (IPNS clock)"
+ - "assertRecipientPinned normalizes both sides to raw pubkey bytes before comparing (no hex/base64 mismatch)"
+ prohibitions:
+ - "MUST NOT bump the node's generation or invent a pin-generation counter — the pin rides inside the existing role-0x01 write-body seal at the current generation (Anti-Pattern)"
+ - "MUST NOT drop existing recipientPins on a folder-metadata update or CAS merge — preserve the union (D-03a)"
+ - "MUST NOT bolt the pin write onto resolveShareEncryptedWriteKey (that only DERIVES a writeKey, never writes a write-body) — this is a genuine new write path (Pitfall 4)"
+ - "MUST NOT add an API/DTO change or call pnpm api:generate — the pin is client-side owner-sealed only (D-03f)"
+---
+
+
+Build the TS pin storage/issuance machinery (D-03a/c): the owner-sealed `NodeWriteBody.recipientPins`
+list is the server-opaque, cross-device source of truth that all three D-03d enforcement consumers
+(80-06 Rust, 80-07 TS, 80-08 web) verify against. There is NO existing SDK method that mutates a node's
+own write-body pin list — `resolveShareEncryptedWriteKey` only DERIVES a writeKey, it never writes back
+(Pitfall 4). This plan adds:
+
+1. Pure sdk-core helpers: `extractRecipientPins` (from a decoded write-body), `appendRecipientPin` (dedup),
+ `assertRecipientPinned` (compare-or-throw, including the D-03e empty/absent hard-fail).
+2. `updateFolderMetadataAndPublish` extended to carry/preserve `recipientPins` through its `sealNode`
+ writeBody and its CAS-409 merge — so pins survive normal folder updates and concurrent writes.
+3. `@cipherbox/sdk` client wrappers `addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` (issuance
+ write: resolve → unseal → append → re-seal → CAS-republish) and `getRecipientPubkeyPins(itemIpnsName)`
+ (read for enforcement).
+
+Depends on 80-01 (the `recipientPins` field must exist on NodeWriteBody). No API/DB change → no api:generate.
+
+Purpose: server-opaque, cross-device recipient-pin storage + the issuance write path.
+Output: pin helpers, pin-preserving publish, and client read/write wrappers, with a round-trip test.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@packages/sdk-core/src/folder/registration.ts
+@packages/sdk-core/src/share/grant.ts
+@packages/sdk/src/client.ts
+@packages/core/src/node/encode.ts
+
+
+
+
+
+ Task 1: RED — round-trip, merge-preservation, and assert-or-throw tests for the pin helpers
+ packages/sdk-core/src/__tests__/share/recipient-pins.test.ts
+
+ - packages/sdk-core/src/folder/registration.ts — `updateFolderMetadataAndPublish` (lines 174-400): the `sealNode` writeBody construction (~320-332), `unsealNode` remote decode (~347-348), and the CAS-409 writeChildren merge (`mergedMap`/`byChildId`, ~379-400)
+ - packages/sdk-core/src/share/grant.ts — `issueReadGrant` (line 80) for the issuance-time data available (recipient pubkey, root node identifiers)
+ - packages/core/src/node/encode.ts (encodeWriteBody) — the `recipientPins` field added by 80-01
+ - packages/sdk/src/client.ts — `resolveShareEncryptedWriteKey` (~3839) and `updateFolderMetadataAndPublish` call sites (~2541/2657/2901) for the resolve→publish idiom
+
+
+ - `assertRecipientPinned(recipient, pins)`: throws when `pins` is empty/undefined (D-03e), throws when
+ `recipient` (normalized to raw bytes) is not a member, returns void on a match.
+ - `appendRecipientPin(pins, recipient)`: returns a deduped list including the recipient.
+ - `extractRecipientPins(writeBody)`: returns the recipientPins list (or []).
+ - Round-trip: after `addRecipientPubkeyPin(itemIpnsName, R)`, `getRecipientPubkeyPins(itemIpnsName)` includes R.
+ - Merge-preservation: an `updateFolderMetadataAndPublish` that changes only writeChildren preserves a
+ pre-existing recipientPins list (and a CAS-409 merge unions local+remote pins).
+ - All fail RED (helpers/params don't exist yet).
+
+
+ Author unit tests in packages/sdk-core/src/__tests__/share/recipient-pins.test.ts covering the pure
+ helpers (assert-or-throw incl. empty-list hard fail, append dedup, extract) and the merge-preservation
+ behavior of updateFolderMetadataAndPublish (seal a node with recipientPins, then update writeChildren,
+ assert pins survive; simulate a CAS-409 remote with different pins, assert union). Author a round-trip
+ test for the client wrappers using the existing test transport/mocks. Do NOT implement yet.
+
+
+ pnpm --filter @cipherbox/sdk-core test recipient-pins 2>&1 | grep -q "fail\|FAIL" && echo "RED confirmed"
+
+
+ - recipient-pins.test.ts exists and references assertRecipientPinned, appendRecipientPin, extractRecipientPins
+ - Tests assert: empty/absent pin list → throw (D-03e), non-member → throw, member → ok, append dedup, extract default []
+ - A merge-preservation test asserts recipientPins survive a writeChildren-only update and a CAS-409 union
+ - All new tests FAIL against current code (non-vacuous RED)
+
+ Failing tests pin the helper contracts, pin-preservation, and the write→read round-trip.
+
+
+
+ Task 2: GREEN — pin helpers + recipientPins preservation in updateFolderMetadataAndPublish
+ packages/sdk-core/src/share/recipient-pins.ts, packages/sdk-core/src/share/index.ts, packages/sdk-core/src/folder/registration.ts
+
+ - packages/sdk-core/src/folder/registration.ts (seal writeBody ~320-332, remote unseal ~347-348, CAS-merge ~379-400)
+ - packages/sdk-core/src/share/index.ts (export surface)
+ - packages/core/src/node — decoded write-body shape with `recipientPins` (from 80-01)
+
+
+ - `updateFolderMetadataAndPublish` seals the writeBody with `recipientPins` = (optional param to set/append) ∪ (current remote pins), never dropping existing pins; on CAS-409 the merged writeBody unions local+remote recipientPins alongside the existing writeChildren merge.
+ - Pure helpers behave per Task 1.
+
+
+ Create packages/sdk-core/src/share/recipient-pins.ts exporting `extractRecipientPins(writeBody)`,
+ `appendRecipientPin(pins, recipient)` (dedup by raw bytes), and `assertRecipientPinned(recipient, pins)`
+ (throw on empty/absent AND on non-member; normalize both sides to raw bytes; use the existing
+ 0x-strip/hex or base64 decode idiom from PATTERNS Shared Patterns). Export from share/index.ts. Extend
+ `updateFolderMetadataAndPublish` to accept an optional `recipientPins` input and thread it (union with
+ the remote write-body's current pins) into the sealed writeBody at seal time and in the CAS-409 merge —
+ generation unchanged, sequenceNumber increments as today. Do NOT add deny_unknown_fields anywhere.
+
+
+ pnpm --filter @cipherbox/sdk-core test recipient-pins 2>&1 | tail -12
+
+
+ - `grep -n "assertRecipientPinned\|appendRecipientPin\|extractRecipientPins" packages/sdk-core/src/share/recipient-pins.ts` shows all three exports
+ - The pure-helper and merge-preservation tests from Task 1 pass
+ - `updateFolderMetadataAndPublish` seals writeBody with the unioned recipientPins (grep shows recipientPins threaded into the sealNode writeBody and the CAS-merge)
+ - `pnpm --filter @cipherbox/sdk-core typecheck` passes
+
+ Pin helpers exist and folder publishes preserve/union recipientPins across updates and CAS merges.
+
+
+
+ Task 3: GREEN — client.addRecipientPubkeyPin (issuance write) + client.getRecipientPubkeyPins (read)
+ packages/sdk/src/client.ts
+
+ - packages/sdk/src/client.ts — `resolveShareEncryptedWriteKey` (~3839) for the resolve+writeKey-derivation idiom, and the `updateFolderMetadataAndPublish` call sites (~2541/2657/2901) for the resolve→seal→publish sequencing and sequenceNumber handling
+ - packages/sdk-core/src/share/recipient-pins.ts (Task 2 helpers) and the extended updateFolderMetadataAndPublish (Task 2)
+
+
+ - `client.addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)`: resolves the node, derives its writeKey (existing walk), unseals the current write-body, appends the pin (dedup), and CAS-republishes via updateFolderMetadataAndPublish with the unioned recipientPins — leaving read-body content and generation unchanged.
+ - `client.getRecipientPubkeyPins(itemIpnsName)`: resolves + unseals the node's write-body and returns its recipientPins (raw-byte list), used by enforcement consumers.
+ - The write→read round-trip test passes.
+
+
+ Add `addRecipientPubkeyPin` and `getRecipientPubkeyPins` to packages/sdk/src/client.ts as thin wrappers
+ over the sdk-core helpers + updateFolderMetadataAndPublish, following the existing resolve→publish
+ pattern (reuse walkChildWriteKey / the writeKey derivation already used by resolveShareEncryptedWriteKey).
+ Do NOT modify resolveShareEncryptedWriteKey. No API/DTO change; do NOT run api:generate.
+
+
+ pnpm --filter @cipherbox/sdk-core test recipient-pins 2>&1 | tail -8; pnpm --filter @cipherbox/sdk typecheck 2>&1 | tail -5
+
+
+ - `grep -n "addRecipientPubkeyPin\|getRecipientPubkeyPins" packages/sdk/src/client.ts` shows both wrappers
+ - The write→read round-trip test passes: adding pin R then reading returns a list containing R
+ - `resolveShareEncryptedWriteKey` is unchanged (diff shows no edit to it)
+ - No new/changed files under packages/api-client/ (no api:generate); `pnpm --filter @cipherbox/sdk typecheck` passes
+
+ The client can write a recipient pin at issuance and read the pin list for enforcement; round-trip green.
+
+
+
+
+
+Consumed by 80-06 (Rust reads via its own InodeTable path, but mirrors the compare semantics), 80-07 (TS enforcement), 80-08 (web):
+- `assertRecipientPinned(recipient, pins)` — pure compare-or-throw incl. D-03e empty/absent hard fail (sdk-core/share/recipient-pins.ts)
+- `extractRecipientPins`, `appendRecipientPin` (sdk-core/share/recipient-pins.ts)
+- `updateFolderMetadataAndPublish` now preserves/unions `recipientPins`
+- `client.addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` — issuance write (used by 80-08)
+- `client.getRecipientPubkeyPins(itemIpnsName)` — read for enforcement (used by 80-07 seam + 80-08)
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| owner (ShareDialog paste) → owner-sealed write-body | Recipient pubkey captured out-of-band at issuance is committed to the server-opaque write-body |
+| owner device → CipherBox relay | Relay stores the sealed write-body as opaque bytes; cannot read/forge pins |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-10 | Spoofing | recipient identity at issuance | high | mitigate | Pin the issuance-time pubkey inside the owner-sealed write-body (D-03a/c) — the trust anchor for all re-mint verification |
+| T-80-11 | Tampering | pin loss on folder update / CAS merge | high | mitigate | updateFolderMetadataAndPublish unions/preserves recipientPins so a routine update can't silently drop the trust anchor |
+| T-80-12 | Elevation of Privilege | empty/absent pin treated as pass | high | mitigate | assertRecipientPinned hard-fails on empty/absent (D-03e no-legacy) |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `pnpm --filter @cipherbox/sdk-core test recipient-pins` green (helpers + merge-preservation + round-trip)
+- `pnpm --filter @cipherbox/sdk-core typecheck` and `pnpm --filter @cipherbox/sdk typecheck` green
+- No packages/api-client changes (no api:generate); generation never bumped for a pin write
+
+
+
+The owner can write an issuance-time recipient pubkey into a node's owner-sealed write-body and read it
+back; pins survive folder updates and CAS merges; and the pure assert helper hard-fails on empty/absent or
+non-member pin lists — all without any API/DB change.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md
new file mode 100644
index 0000000000..b66ed8789e
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md
@@ -0,0 +1,180 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 04
+subsystem: api
+tags: [sharing, recipient-pins, write-body, ipns, cas, node-codec, secp256k1]
+
+# Dependency graph
+requires:
+ - phase: 80-01
+ provides: "NodeWriteBody.recipientPins (TS) / recipient_pins (Rust) codec field with round-trip encode/decode"
+provides:
+ - "assertRecipientPinned / appendRecipientPin / extractRecipientPins pure helpers (sdk-core/share/recipient-pins.ts)"
+ - "updateFolderMetadataAndPublish preserves + unions recipientPins across folder updates and CAS-409 merges"
+ - "client.addRecipientPubkeyPin(itemIpnsName, recipientPublicKey) issuance write path"
+ - "client.getRecipientPubkeyPins(itemIpnsName) enforcement read path"
+ - "getWriteBodyParams surfaces recipientPins so routine folder updates preserve them"
+affects: [80-06, 80-07, 80-08]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Owner-sealed recipient-pin list as the server-opaque cross-device re-mint trust anchor"
+ - "Monotonic dedup-union of pins across a CAS-409 (never pruned, unlike write-chain entries)"
+ - "Both-sides raw-byte normalization (Uint8Array / hex / base64) before pin compare"
+
+key-files:
+ created:
+ - packages/sdk-core/src/share/recipient-pins.ts
+ - packages/sdk-core/src/__tests__/share/recipient-pins.test.ts
+ modified:
+ - packages/sdk-core/src/share/index.ts
+ - packages/sdk-core/src/index.ts
+ - packages/sdk-core/src/folder/registration.ts
+ - packages/sdk/src/client.ts
+ - packages/sdk/src/write-body-params.ts
+
+key-decisions:
+ - "Pins are a monotonic UNION on CAS-409 (a pin is a permanent trust anchor, never pruned) — distinct from the base-aware write-chain prune"
+ - "getWriteBodyParams surfaces recipientPins so ALL client folder updates thread current pins through and preserve them on clean publishes (closes T-80-11 generically, not just for the issuance path)"
+ - "Client wrappers take a 2-arg (itemIpnsName, recipientPublicKey) signature and operate on a folder the client tracks as a FolderState — its own writeKey/ipnsKeypair seal its write-body"
+
+patterns-established:
+ - "Pure pin helpers own the D-03d compare semantics; the three enforcement consumers (80-06/07/08) verify against them"
+ - "encodeWriteBody omits an empty recipientPins list so the frozen empty-pin KAT is byte-preserved"
+
+requirements-completed:
+ - "SC2 / D-03a: store the issuance-time recipient pubkey in the shared root node's owner-sealed NodeWriteBody (server-opaque, cross-device)"
+ - "SC2 / D-03c: at grant creation, append the pasted recipient pubkey to the node's write-body pin list and republish"
+
+coverage:
+ - id: D1
+ description: "assertRecipientPinned throws on empty/absent pin list (D-03e) and non-member; returns void on a raw-byte match; normalizes hex/base64/bytes"
+ requirement: "SC2 / D-03a"
+ verification:
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#assertRecipientPinned"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "appendRecipientPin dedups by raw bytes across encodings; extractRecipientPins defaults to []"
+ requirement: "SC2 / D-03c"
+ verification:
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#appendRecipientPin / extractRecipientPins"
+ status: pass
+ human_judgment: false
+ - id: D3
+ description: "updateFolderMetadataAndPublish seals recipientPins and unions local ∪ remote pins across a CAS-409 (T-80-11 durability)"
+ requirement: "SC2 / D-03a"
+ verification:
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#recipientPins durability (T-80-11)"
+ status: pass
+ human_judgment: false
+ - id: D4
+ description: "Write→read round-trip at the sdk-core seal boundary: append pin → seal → unseal → extract returns the pin"
+ requirement: "SC2 / D-03c"
+ verification:
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#write→read round-trip"
+ status: pass
+ human_judgment: false
+ - id: D5
+ description: "client.addRecipientPubkeyPin (issuance write, generation unchanged) + client.getRecipientPubkeyPins (raw-byte read)"
+ requirement: "SC2 / D-03c"
+ verification:
+ - kind: other
+ ref: "pnpm --filter @cipherbox/sdk typecheck (thin wrappers over sdk-core helpers; sdk-core cannot import sdk, so runtime is proxied by the D4 seal-boundary round-trip)"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 9min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 04: Recipient-Pin Storage and Issuance Write Path Summary
+
+**Owner-sealed `NodeWriteBody.recipientPins` machinery — pure compare/append/extract helpers, pin-preserving folder publish with CAS-409 union, and `client.addRecipientPubkeyPin`/`getRecipientPubkeyPins` wrappers — the server-opaque cross-device trust anchor for D-03d re-mint enforcement.**
+
+## Performance
+
+- **Duration:** ~9 min
+- **Started:** 2026-07-12T20:11:00Z
+- **Completed:** 2026-07-12T20:20:00Z
+- **Tasks:** 3
+- **Files modified:** 5 (2 created, 3+2 modified)
+
+## Accomplishments
+- Pure helpers `assertRecipientPinned` / `appendRecipientPin` / `extractRecipientPins` — `assertRecipientPinned` fails closed on an empty/absent pin list (D-03e no-legacy) and on a non-member, normalizing both sides to raw pubkey bytes.
+- `updateFolderMetadataAndPublish` gains an optional `recipientPins` param, threaded into the sealed write-body and unioned with the remote write-body's pins on a CAS-409 merge — pins are never silently dropped (T-80-11).
+- `client.addRecipientPubkeyPin` resolves the item, appends the recipient pin (dedup), and CAS-republishes at the UNCHANGED node generation (sequenceNumber advances); `client.getRecipientPubkeyPins` reads the pin list back as raw bytes for enforcement.
+- `getWriteBodyParams` now surfaces `recipientPins`, so every routine client folder update threads current pins through the publish and preserves them on clean publishes.
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: RED tests** - `7d4a1f5e8` (test)
+2. **Task 2: GREEN helpers + pin-preserving publish** - `5dc6ffd21` (feat)
+3. **Task 3: GREEN client wrappers** - `4ef3fd2f7` (feat)
+
+_Note: this is a `type: tdd` plan — RED (`test`) precedes GREEN (`feat`) in git history._
+
+## Files Created/Modified
+- `packages/sdk-core/src/share/recipient-pins.ts` - pure pin helpers + raw-byte normalization (created)
+- `packages/sdk-core/src/__tests__/share/recipient-pins.test.ts` - helper + durability + round-trip tests (created)
+- `packages/sdk-core/src/share/index.ts` - export the three helpers + `RecipientPubkey`
+- `packages/sdk-core/src/index.ts` - re-export helpers from the sdk-core barrel
+- `packages/sdk-core/src/folder/registration.ts` - thread `recipientPins` into the seal + CAS-409 union
+- `packages/sdk/src/client.ts` - `addRecipientPubkeyPin` / `getRecipientPubkeyPins` wrappers
+- `packages/sdk/src/write-body-params.ts` - surface `recipientPins` from the write-body
+
+## Decisions Made
+- **Pins union, never prune, on CAS-409.** A recipient pin is a permanent trust anchor, so the merge is a plain dedup-union of local ∪ remote (reusing `appendRecipientPin`), unlike the base-aware write-chain prune that honors deletes.
+- **`getWriteBodyParams` surfaces `recipientPins`.** This makes preservation generic: every client folder-update call site that spreads `...writeBodyParams` now threads the current pins through, so a routine rename/move/add never drops them on a clean publish — not only the issuance path.
+- **2-arg client signature operating on a tracked folder.** The plan's `addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` signature carries no parent, so the item is treated as a folder the client tracks (its own `FolderState` supplies the writeKey + IPNS signing key to seal its write-body). Fails closed when the item is not write-capable.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Extended `getWriteBodyParams` to return `recipientPins`**
+- **Found during:** Task 3 (client wrappers)
+- **Issue:** The client wrappers must read the item's CURRENT pins to append/union, but `getWriteBodyParams` returned only `{ writeKey, writeChildren }` — there was no way to read the pins without a second resolve+unseal.
+- **Fix:** Added an additive optional `recipientPins?: string[]` to `getWriteBodyParams`'s return (sourced from the metadata mirror or the on-wire unseal), plus the matching private-delegate return type in `client.ts`. Beneficial side effect: all existing update call sites that spread `...writeBodyParams` now preserve pins generically (T-80-11).
+- **Files modified:** packages/sdk/src/write-body-params.ts, packages/sdk/src/client.ts
+- **Verification:** `pnpm --filter @cipherbox/sdk typecheck` passes; additive optional field, no wire change (empty list omitted by `encodeWriteBody`).
+- **Committed in:** `4ef3fd2f7` (Task 3 commit)
+
+---
+
+**Total deviations:** 1 auto-fixed (1 blocking)
+**Impact on plan:** The extension is additive and required to satisfy the plan's own key_link ("unseals its current write-body ... appends the pin"). No scope creep; no API/DB change.
+
+## Issues Encountered
+- The `@cipherbox/sdk` typecheck reads `@cipherbox/sdk-core`'s built dist, so `@cipherbox/core` and `@cipherbox/sdk-core` dists were rebuilt after adding the new exports/param before the sdk typecheck (documented setup step). No source issues.
+- The write→read round-trip is authored at the sdk-core seal boundary (append → `updateFolderMetadataAndPublish` → `unsealNode` → `extractRecipientPins`) because sdk-core cannot import `@cipherbox/sdk`; the thin client wrappers delegate to exactly this path and are covered by `@cipherbox/sdk` typecheck.
+
+## Prohibitions honored
+- Node generation is NEVER bumped and no pin-generation counter was added — the pin rides inside the existing role-0x01 write-body seal at the current generation; only the IPNS `sequenceNumber` increments.
+- `resolveShareEncryptedWriteKey` is unchanged (no pin write bolted onto the writeKey-derivation path — Pitfall 4).
+- No API/DTO change and no `pnpm api:generate`; no DB migration (D-03f) — the pin is client-side owner-sealed only.
+- No `deny_unknown_fields` / forward-tolerance regressions; empty pin list stays off the wire.
+
+## Verification
+- `pnpm --filter @cipherbox/sdk-core test recipient-pins` — 17 passed (17).
+- `pnpm --filter @cipherbox/sdk-core typecheck` — pass.
+- `pnpm --filter @cipherbox/sdk typecheck` — pass.
+- No `packages/api-client/` changes.
+
+## Next Phase Readiness
+- Pin storage + issuance write + enforcement read are ready for the D-03d consumers: 80-06 (Rust re-mint compare), 80-07 (TS `reMintGrantsRootedAt` compare), 80-08 (web ShareDialog issuance wiring).
+- The pure `assertRecipientPinned` is the shared compare semantics those consumers mirror (Rust reads via its own InodeTable path but matches the empty/absent hard-fail).
+
+---
+*Phase: 80-rotation-write-plane-and-re-mint-durability*
+*Completed: 2026-07-12*
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-PLAN.md
new file mode 100644
index 0000000000..53502d20b0
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-PLAN.md
@@ -0,0 +1,202 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 05
+type: tdd
+wave: 2
+depends_on: ["80-01", "80-02"]
+files_modified:
+ - crates/sdk/src/listing.rs
+ - crates/fuse/src/inode.rs
+ - crates/fuse/src/write_ops/rotation_deps.rs
+autonomous: true
+requirements:
+ - "SC2 / D-03a: surface + cache the shared node's owner-sealed recipient pins so the FUSE re-mint can verify them offline"
+ - "SC1 / D-01: rotation republish must PRESERVE the recipient pins in the reconstructed write-body (else a later re-mint hard-fails D-03e)"
+user_setup: []
+
+must_haves:
+ truths:
+ - "When the FUSE mount materializes an owned node, the node's recipientPins (from its unsealed write-body) are cached on the inode (D-03a)"
+ - "reconstruct_write_body includes the cached recipientPins so a rotation republish preserves them — a subsequent re-materialize + re-mint still finds the pins (D-01 + D-03e durability)"
+ - "ResolvedOwnedChild carries the node's recipient pins alongside its keys (D-03a)"
+ artifacts:
+ - "crates/sdk/src/listing.rs — ResolvedOwnedChild.recipient_pins populated from the unsealed write-body"
+ - "crates/fuse/src/inode.rs — InodeKind recipient_pins cache field + apply_owned_children population"
+ - "crates/fuse/src/write_ops/rotation_deps.rs — reconstruct_write_body carries cached recipient_pins into the resealed write-body"
+ key_links:
+ - "listing.rs already unseals+decodes the write-body (write_body.ipns_private_key at ~:543-544) — recipient_pins are read from the SAME decoded write_body at the ResolvedOwnedChild construction (~:546)"
+ - "apply_owned_children destructures ResolvedOwnedChild and moves recipient_pins onto the materialized inode; reconstruct_write_body reads them back for republish preservation"
+ prohibitions:
+ - "MUST NOT drop recipientPins during rotation republish — reconstruction MUST carry the cached pins (otherwise D-03 self-destructs after the first rotation)"
+ - "MUST NOT log pin bytes as secret — recipient pins are PUBLIC keys, but keep the InodeKind Debug redaction discipline intact for read_key/write_key/ipns_private_key"
+ - "MUST NOT change the read plane / generation — pins live only in the write-body"
+---
+
+
+Thread the D-03a recipient pins from the shared node's owner-sealed `NodeWriteBody` into the FUSE mount's
+in-memory state so the Rust re-mint (80-06) can verify them OFFLINE (mirroring D-01's "read from the
+already-mounted InodeTable" pattern), and so D-01's rotation republish PRESERVES them.
+
+This is the pin-plumbing prerequisite for the Rust enforcement in 80-06, and it closes a subtle D-01↔D-03
+interaction: D-01's `reconstruct_write_body` (80-02) rebuilds the write-body from InodeTable-DERIVED
+material (keys, child refs). Recipient pins are NOT derivable — they are issuance data stored only in the
+published write-body. So unless the pins are cached on the inode at materialization AND re-emitted by
+reconstruction, a scope-exit rotation would republish the node WITHOUT pins, and the next re-mint
+(after re-materialize) would hard-fail closed (D-03e). This plan caches pins at materialization and carries
+them through reconstruction.
+
+Depends on 80-01 (the write-body pin field) and 80-02 (reconstruct_write_body already handles keys+children).
+
+Purpose: make the shared node's recipient pins available offline to the FUSE re-mint and durable across rotation.
+Output: pins surfaced on ResolvedOwnedChild, cached on the inode, and preserved by reconstruction.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@crates/sdk/src/listing.rs
+@crates/fuse/src/inode.rs
+@crates/fuse/src/write_ops/rotation_deps.rs
+
+
+
+
+
+ Task 1: RED — reconstruction-preserves-pins + materialization-caches-pins tests
+ crates/fuse/src/write_ops/rotation_deps.rs, crates/fuse/src/inode.rs
+
+ - crates/sdk/src/listing.rs — `ResolvedOwnedChild` struct (lines 124-142); the write-body unseal + `decode_write_body` + `write_body.ipns_private_key` extraction (~536-544); the `Ok(ResolvedOwnedChild { ... })` construction (~546)
+ - crates/fuse/src/inode.rs — `InodeKind` variants (119-172) each with read_key/write_key/ipns_private_key; the Debug redaction impl (181-216); `apply_owned_children` destructure `ResolvedOwnedChild { child, node_id, read_key, write_key, ipns_private_key }` (~466); the root init construction (~299)
+ - crates/fuse/src/write_ops/rotation_deps.rs — `reconstruct_write_body` (added by 80-02) and its NodeWriteBody construction
+ - crates/core/src/node — `NodeWriteBody.recipient_pins` (from 80-01)
+
+
+ - Test A (materialization caches pins): after apply_owned_children with a ResolvedOwnedChild carrying a non-empty recipient_pins list, the materialized inode's cached recipient_pins equal the input.
+ - Test B (reconstruction preserves pins): reconstruct_write_body for a node whose inode caches recipient_pins produces a write_sealed that unseals to a NodeWriteBody whose recipient_pins equal the cached list (and whose keys/children still match 80-02's contract).
+ - Both fail RED (no recipient_pins field on ResolvedOwnedChild/InodeKind yet; reconstruction doesn't emit pins).
+
+
+ Extend the rotation_deps.rs `#[cfg(test)]` module (and inode.rs tests if that is where apply_owned_children
+ is exercised) with Tests A and B. Reuse 80-02's reconstruction round-trip harness, adding a cached
+ recipient_pins list to the fixture inode and asserting the resealed body round-trips the pins. Do NOT
+ implement the field/threading yet.
+
+
+ cargo test -p cipherbox-fuse rotation_deps 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed"
+
+
+ - Tests A and B exist and reference a cached recipient_pins list on the inode/ResolvedOwnedChild
+ - Both FAIL against current code (non-vacuous RED)
+ - Test B asserts the reconstructed write-body round-trips BOTH the pins AND the keys/children (no regression of 80-02)
+
+ Failing tests pin the materialization-caches-pins and reconstruction-preserves-pins contracts.
+
+
+
+ Task 2: GREEN — surface recipient_pins on ResolvedOwnedChild and cache on the inode
+ crates/sdk/src/listing.rs, crates/fuse/src/inode.rs
+
+ - crates/sdk/src/listing.rs (write-body decode ~543, ResolvedOwnedChild construct ~546, Debug redaction 144-154)
+ - crates/fuse/src/inode.rs (InodeKind 119-172, Debug 181-216, apply_owned_children destructure ~466, root init ~299)
+
+
+ - `ResolvedOwnedChild.recipient_pins: Vec>` populated from `write_body.recipient_pins` at construction.
+ - `InodeKind::{Root,Folder,File}` gain a `recipient_pins: Vec>` field, populated in apply_owned_children from `owned.recipient_pins`; all construction sites (materialization, root init, test constructors) supply it (empty default where none).
+ - Existing `..` match arms compile unchanged; Debug shows recipient_pins via `..` or as a non-secret field (pins are public keys; keep key material redacted).
+
+
+ Add `recipient_pins: Vec>` to `ResolvedOwnedChild` and populate it from the already-decoded
+ `write_body.recipient_pins` (listing.rs ~:546); update its Debug impl (non-secret, may print or elide).
+ Add `recipient_pins: Vec>` to each `InodeKind` struct variant; populate in apply_owned_children
+ from the destructured `owned.recipient_pins`; default to empty at the root init and any test constructor.
+ Fix all construction sites the compiler flags. Do NOT weaken the key-material redaction in the Debug impl.
+
+
+ cargo test -p cipherbox-fuse inode 2>&1 | tail -8; cargo build -p cipherbox-fuse -p cipherbox-sdk 2>&1 | tail -5
+
+
+ - `grep -n "recipient_pins" crates/sdk/src/listing.rs` shows the field on ResolvedOwnedChild populated from write_body
+ - `grep -c "recipient_pins" crates/fuse/src/inode.rs` shows the field on the InodeKind variants + apply_owned_children population
+ - `cargo build -p cipherbox-fuse -p cipherbox-sdk` compiles (all construction sites updated); Test A passes
+ - InodeKind Debug still redacts read_key/write_key/ipns_private_key
+
+ Recipient pins flow from the unsealed write-body onto ResolvedOwnedChild and are cached on the materialized inode.
+
+
+
+ Task 3: GREEN — reconstruct_write_body carries cached recipient_pins (D-01 durability)
+ crates/fuse/src/write_ops/rotation_deps.rs
+
+ - crates/fuse/src/write_ops/rotation_deps.rs — `reconstruct_write_body` (from 80-02): its NodeWriteBody construction from InodeTable-sourced write_key + ipns_private_key + child WriteChildRefs
+ - crates/fuse/src/inode.rs — the inode `recipient_pins` cache added in Task 2
+
+
+ - `reconstruct_write_body` reads the node's cached recipient_pins from the InodeTable and includes them in the reconstructed NodeWriteBody, so `seal_node` emits a write_sealed that preserves the pins.
+ - Round-trip (Test B) passes: unseal recovers keys + children + pins.
+
+
+ Extend `reconstruct_write_body` to pull the node's cached `recipient_pins` (from the same inode it reads
+ write_key/ipns_private_key from) and set `NodeWriteBody.recipient_pins` before `encode_write_body`/
+ `seal_node`. Non-materialized nodes still fail open to None (unchanged from 80-02). Key bytes never logged.
+
+
+ cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8; cargo test -p cipherbox-fuse replay 2>&1 | tail -5
+
+
+ - `grep -n "recipient_pins" crates/fuse/src/write_ops/rotation_deps.rs` shows pins threaded into the reconstructed NodeWriteBody
+ - Test B passes: the resealed write-body round-trips the cached recipient_pins AND the keys/children
+ - 80-02's None-fallback and replay tests still pass (no regression)
+ - `cargo test -p cipherbox-fuse` green
+
+ Rotation republish preserves recipient pins in the reconstructed write-body; re-materialize + future re-mint still find them.
+
+
+
+
+
+Consumed by 80-06 (Rust enforcement seam reads pins from the InodeTable cache):
+- `ResolvedOwnedChild.recipient_pins: Vec>` (listing.rs)
+- `InodeKind::{Root,Folder,File}.recipient_pins: Vec>` cache + apply_owned_children population (inode.rs)
+- `reconstruct_write_body` now emits recipient_pins (rotation_deps.rs) — closes the D-01↔D-03 preservation gap
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| published write-body → in-memory InodeTable | Owner-sealed pins decoded once at materialization, cached for offline verification |
+| rotation republish → future re-materialize | Pins must survive republish or the next re-mint hard-fails |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-13 | Denial of Service | pin loss on rotation republish | high | mitigate | reconstruct_write_body carries cached pins so re-mint doesn't hard-fail after a rotation (D-01↔D-03e) |
+| T-80-14 | Tampering | InodeTable pin cache as verification source | medium | mitigate | Pins sourced from the owner-sealed write-body at materialization — a relay cannot inject them into the sealed body |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `cargo build -p cipherbox-fuse -p cipherbox-sdk` compiles (all InodeKind construction sites updated)
+- `cargo test -p cipherbox-fuse` green (materialization caches pins; reconstruction preserves pins; 80-02 regressions intact)
+- InodeKind Debug redaction of key material intact
+
+
+
+Recipient pins flow from the shared node's owner-sealed write-body onto the materialized inode and are
+preserved by rotation republish, making them available offline to the FUSE re-mint and durable across rotation.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md
new file mode 100644
index 0000000000..426933e8aa
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md
@@ -0,0 +1,160 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 05
+subsystem: infra
+tags: [rust, fuse, ipns, rotation, recipient-pins, node-v3, zeroize]
+
+# Dependency graph
+requires:
+ - phase: 80-01
+ provides: NodeWriteBody.recipient_pins wire field
+ - phase: 80-02
+ provides: reconstruct_write_body helper + job-scoped sent-shares cache in rotation_deps.rs
+provides:
+ - "ResolvedOwnedChild.recipient_pins surfaced from the unsealed write-body (listing.rs)"
+ - "InodeKind::{Root,Folder,File}.recipient_pins cache field + apply_owned_children population (inode.rs)"
+ - "reconstruct_write_body now carries cached recipient_pins into the resealed write-body (rotation_deps.rs)"
+affects: [80-06]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "D-03a pin plumbing: issuance data (recipient pins) surfaced once at materialization from the same unsealed write-body, cached on the inode for offline verification"
+ - "D-01↔D-03e durability: rotation republish reconstruction re-emits cached pins verbatim so a later re-mint after re-materialize still finds them"
+
+key-files:
+ created:
+ - .planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md
+ modified:
+ - crates/sdk/src/listing.rs
+ - crates/fuse/src/inode.rs
+ - crates/fuse/src/write_ops/rotation_deps.rs
+ - crates/fuse/src/fs.rs
+ - crates/fuse/src/replay.rs
+ - crates/fuse/src/test_support.rs
+ - crates/fuse/src/write_ops/grant_scope.rs
+ - crates/fuse/src/write_ops/implementation/delete.rs
+ - crates/fuse/src/write_ops/implementation/file_data.rs
+ - crates/fuse/src/write_ops/implementation/mkdir.rs
+ - crates/fuse/src/write_ops/implementation/rename.rs
+ - crates/fuse/src/platform/windows/write_ops.rs
+
+key-decisions:
+ - "Recipient pins are PUBLIC keys, not secret material — surfaced as recipient_pins_count in Debug impls, NOT redacted like read_key/write_key/ipns_private_key"
+ - "Fresh nodes (mkdir, new file, root init, test fixtures) default to an empty pin list; only materialized owned nodes carry real pins"
+ - "reconstruct_write_body reads pins from the SAME inode it reads write_key/ipns_private_key from — copied verbatim, never rotated (read plane / generation untouched)"
+
+patterns-established:
+ - "Pin plumbing mirror of the ipns_private_key path: read from the unsealed write-body at ResolvedOwnedChild construction, moved onto InodeKind at apply_owned_children, re-emitted by reconstruction"
+
+requirements-completed:
+ - "SC2 / D-03a: surface + cache the shared node's owner-sealed recipient pins so the FUSE re-mint can verify them offline"
+ - "SC1 / D-01: rotation republish must PRESERVE the recipient pins in the reconstructed write-body (else a later re-mint hard-fails D-03e)"
+
+coverage:
+ - id: D1
+ description: "ResolvedOwnedChild.recipient_pins populated from the already-unsealed write-body (listing.rs)"
+ requirement: "SC2 / D-03a"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/inode.rs#apply_owned_children_caches_recipient_pins_on_the_inode (exercises pins flowing from ResolvedOwnedChild onto the inode)"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "InodeKind::{Root,Folder,File}.recipient_pins cache field populated in apply_owned_children; key-material Debug redaction preserved"
+ requirement: "SC2 / D-03a"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/inode.rs#apply_owned_children_caches_recipient_pins_on_the_inode"
+ status: pass
+ human_judgment: false
+ - id: D3
+ description: "reconstruct_write_body carries cached recipient_pins into the resealed write-body so a rotation republish preserves them (D-01↔D-03e)"
+ requirement: "SC1 / D-01"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_preserves_cached_recipient_pins"
+ status: pass
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_round_trips_ipns_key_and_child_write_refs (80-02 no-regression)"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 30min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 05: Recipient-Pin Plumbing for Offline Re-Mint + Rotation Durability Summary
+
+**D-03a recipient pins now flow from the shared node's owner-sealed write-body onto the materialized inode and are preserved verbatim by rotation republish, making them available offline to the FUSE re-mint (80-06) and durable across a scope-exit rotation.**
+
+## Performance
+
+- **Duration:** ~30 min
+- **Started:** 2026-07-12
+- **Completed:** 2026-07-12
+- **Tasks:** 3 (TDD RED → GREEN → GREEN)
+- **Files modified:** 12
+
+## Accomplishments
+- `ResolvedOwnedChild.recipient_pins: Vec>` read from the SAME already-decoded `write_body` as `ipns_private_key` in `resolve_owned_child` (listing.rs) — no second unseal.
+- `InodeKind::{Root,Folder,File}` gained a `recipient_pins: Vec>` cache field, populated in `apply_owned_children` by moving `owned.recipient_pins` onto the materialized inode; empty default at root init and all fresh-node/test construction sites.
+- `reconstruct_write_body` (from 80-02) now reads the node's cached `recipient_pins` from the InodeTable and sets `NodeWriteBody.recipient_pins` before `seal_node`, so a scope-exit rotation republish PRESERVES the pins (closes the D-01↔D-03e self-destruct gap where a post-rotation re-mint would hard-fail).
+- Debug discipline held: recipient pins (public keys) surface as `recipient_pins_count`; `read_key`/`write_key`/`ipns_private_key` remain ``.
+
+## Task Commits
+
+Single squashed commit per execution constraint (SUMMARY committed alongside code):
+
+1. **Task 1: RED — reconstruction-preserves-pins + materialization-caches-pins tests** (test)
+2. **Task 2: GREEN — surface recipient_pins on ResolvedOwnedChild + cache on the inode** (feat)
+3. **Task 3: GREEN — reconstruct_write_body carries cached recipient_pins** (feat)
+
+RED was confirmed as a non-vacuous compile failure (`no field recipient_pins on ResolvedOwnedChild`; `variant InodeKind::Folder/File does not have a field named recipient_pins`) before implementation.
+
+## Files Created/Modified
+- `crates/sdk/src/listing.rs` — `ResolvedOwnedChild.recipient_pins` field + Debug + populated from `write_body.recipient_pins` at construction
+- `crates/fuse/src/inode.rs` — `InodeKind` variant field + Debug (non-secret count) + `apply_owned_children` destructure/population + root init + test fixtures + Test A
+- `crates/fuse/src/write_ops/rotation_deps.rs` — `reconstruct_write_body` reads cached pins into the resealed `NodeWriteBody` + doc comment + Test B + test-helper fixture
+- `crates/fuse/src/{fs.rs,replay.rs,test_support.rs}` — construction sites updated (empty default)
+- `crates/fuse/src/write_ops/{grant_scope.rs,implementation/{delete,file_data,mkdir,rename}.rs}` — construction sites updated (empty default)
+- `crates/fuse/src/platform/windows/write_ops.rs` — winfsp construction sites updated (empty default) to keep the Windows CI build green
+
+## Decisions Made
+- Recipient pins are public keys → shown as `recipient_pins_count` in Debug, not redacted. Key material redaction unchanged (crypto rule #2).
+- Fresh/newly-created nodes and all test fixtures default to an empty pin list; only materialized owned nodes carry real pins.
+- Read plane / generation untouched — pins live only in the write-body and are copied, never rotated.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Updated all InodeKind construction sites across the fuse crate (incl. winfsp)**
+- **Found during:** Task 2 (adding the `recipient_pins` field to the `InodeKind` variants)
+- **Issue:** Adding a required struct-variant field forces every literal construction site to supply it, or the crate (and its test build) will not compile. The plan named only listing.rs/inode.rs/rotation_deps.rs, but the compiler flagged additional lib + test construction sites in fs.rs, replay.rs, test_support.rs, grant_scope.rs, delete.rs, file_data.rs, mkdir.rs, rename.rs, and the winfsp platform module.
+- **Fix:** Supplied `recipient_pins: Vec::new()` at each fresh-node/test construction site (no share grants at creation → empty pins). The winfsp `platform/windows/write_ops.rs` sites were updated by inspection to avoid breaking the Windows-only CI build (local cargo does not compile `windows/*`).
+- **Files modified:** fs.rs, replay.rs, test_support.rs, grant_scope.rs, delete.rs, file_data.rs, mkdir.rs, rename.rs, platform/windows/write_ops.rs
+- **Verification:** `cargo build -p cipherbox-fuse -p cipherbox-sdk` compiles; `cargo test -p cipherbox-fuse` = 126 passed / 0 failed.
+- **Committed in:** same plan commit
+
+---
+
+**Total deviations:** 1 auto-fixed (1 blocking — mechanical fan-out of a required field addition, explicitly anticipated by the plan's "Fix all construction sites the compiler flags").
+**Impact on plan:** No scope creep — all changes are the direct compile-required consequence of the specified `InodeKind` field. No behavior changed at the empty-default sites.
+
+## Issues Encountered
+None. The winfsp sites cannot be compiled locally (macOS/CI split, per project memory), so they were updated by inspection matching the fuse-side pattern — budget a CI round-trip for the Windows build.
+
+## User Setup Required
+None - no external service configuration required.
+
+## Next Phase Readiness
+- 80-06 (Rust enforcement seam) can now read the recipient pins from the InodeTable cache and verify them offline; pins survive a scope-exit rotation republish.
+- No API change, no DB migration, no `pnpm api:generate` (Rust-only, write-body-internal).
+
+---
+*Phase: 80-rotation-write-plane-and-re-mint-durability*
+*Completed: 2026-07-12*
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-PLAN.md
new file mode 100644
index 0000000000..3fd0c8ce92
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-PLAN.md
@@ -0,0 +1,172 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 06
+type: tdd
+wave: 3
+depends_on: ["80-01", "80-05"]
+files_modified:
+ - crates/fuse/src/write_ops/rotation_deps.rs
+ - crates/sdk/src/rotation/engine.rs
+autonomous: true
+requirements:
+ - "SC2 / D-03d (consumer 1 of 3): Rust re-mint verifies grant.recipient_public_key against the node's owner-sealed pin before wrap_key, fail-closed on mismatch"
+ - "SC2 / D-03e: pin absent at re-mint is a hard fail-closed invariant violation (no-legacy, no TOFU, no backfill)"
+user_setup: []
+
+must_haves:
+ truths:
+ - "re_mint_grants_rooted_at fetches the node's recipient pins via a RotationDeps seam and fails the whole node's re-mint closed if grant.recipient_public_key is not pinned (D-03d)"
+ - "A pin-absent (empty) pin list at re-mint is a hard RotateFailed, not a skip (D-03e no-legacy)"
+ - "FuseRotationDeps resolves the pin list OFFLINE from the InodeTable pin cache (80-05), no extra network fetch (D-03a)"
+ artifacts:
+ - "crates/fuse/src/write_ops/rotation_deps.rs — get_recipient_pubkey_pins seam on FuseRotationDeps reading the InodeTable pin cache"
+ - "crates/sdk/src/rotation/engine.rs — RotationDeps::get_recipient_pubkey_pins + fail-closed compare before wrap_key in re_mint_grants_rooted_at"
+ - "pin-mismatch + pin-absent fail-closed tests in the rotation_deps.rs test module"
+ key_links:
+ - "The compare runs immediately before cipherbox_crypto::wrap_key(new_read_key, &grant.recipient_public_key) at engine.rs:610, using pins read from THIS node's own write-body (not the /shares/sent response)"
+ - "Pin bytes and the grant pubkey are normalized to raw bytes before comparison (no 0x/hex mismatch)"
+ prohibitions:
+ - "MUST NOT model a pin mismatch as a per-grant skip-and-continue like the is_revoked branch — it aborts the node's re-mint closed (D-03e, Pitfall 5)"
+ - "MUST NOT touch the 4th co-writer re-wrap site rotateWriteFromNode (crates/sdk/src/rotation/engine.rs ~:2762) — CONTEXT names exactly 3 consumers; this write-revocation site is OUT OF SCOPE and recorded as a follow-up assumption (RESEARCH Open Question 2 / A3)"
+ - "MUST NOT trust the /shares/sent recipient_public_key as the pin source — the pin comes only from the node's owner-sealed write-body"
+---
+
+
+Consumer 1 of D-03d's three fail-closed enforcement sites: the Rust FUSE re-mint. Today
+`re_mint_grants_rooted_at` calls `wrap_key(new_read_key, &grant.recipient_public_key)` (engine.rs:610)
+where `grant.recipient_public_key` came straight back through the relay via `GET /shares/sent` — a
+compromised relay could substitute it and cause the owner to ECIES-wrap the fresh post-rotation read key
+TO THE ATTACKER. This plan inserts a pin comparison: before every wrap, verify `grant.recipient_public_key`
+is a member of the node's OWN owner-sealed `recipientPins` (read OFFLINE from the InodeTable pin cache built
+in 80-05), and fail the node's re-mint closed on mismatch OR on an absent/empty pin list (D-03e no-legacy).
+
+Depends on 80-05 (InodeTable pin cache + ResolvedOwnedChild pins) and 80-01 (the field). rotation_deps.rs
+overlaps 80-05 → this is wave 3.
+
+Purpose: stop the Rust re-mint from wrapping the read key to a relay-substituted recipient.
+Output: a RotationDeps pin seam + fail-closed compare + mismatch/absent regression tests.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@crates/sdk/src/rotation/engine.rs
+@crates/fuse/src/write_ops/rotation_deps.rs
+
+
+
+
+
+ Task 1: RED — pin-mismatch and pin-absent fail-closed tests for re_mint_grants_rooted_at
+ crates/fuse/src/write_ops/rotation_deps.rs
+
+ - crates/sdk/src/rotation/engine.rs — `GrantRow` (line 121, `recipient_public_key: Vec` at :124); `RotationDeps` trait (146) and its `query_grants_rooted_at` default (180); `re_mint_grants_rooted_at` (597-626) with the `wrap_key(new_read_key, &grant.recipient_public_key)` call at :610 and the is_revoked skip branch; the OUT-OF-SCOPE `rotateWriteFromNode` co-writer re-wrap (~:2762) — do NOT modify
+ - crates/fuse/src/write_ops/rotation_deps.rs — the `#[cfg(test)]` FakeTransport/FuseRotationDeps test constructors; the InodeTable pin cache from 80-05; `find_grant_root_state`/`find_ipns_private_key` lookup idiom (552-599)
+
+
+ - Test A (mismatch): re_mint over a node whose cached pins do NOT contain grant.recipient_public_key returns Err(RotateFailed) and does NOT call update_grant/wrap for that grant.
+ - Test B (absent): re_mint over a node with an EMPTY pin list returns Err(RotateFailed) (D-03e hard fail), not a silent skip.
+ - Test C (match): re_mint over a node whose pins DO contain the grant pubkey proceeds and wraps as before (retained recipient re-minted; revoked recipient cut by absence).
+ - A/B fail RED (no seam/compare exists); C passes today but must still pass after the change.
+
+
+ Add a `get_recipient_pubkey_pins` fixture to the test RotationDeps/FakeTransport so tests can inject a
+ node's pin list. Author Tests A/B/C in the rotation_deps.rs test module (or engine.rs test module,
+ wherever re_mint is currently exercised). Assert the mismatch/absent cases produce RotateFailed and skip
+ the wrap. Do NOT implement the seam/compare yet.
+
+
+ cargo test -p cipherbox-fuse rotation_deps 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed"
+
+
+ - Tests A/B/C exist and inject a per-node pin list via a get_recipient_pubkey_pins fixture
+ - A (mismatch) and B (absent/empty) FAIL against current code (non-vacuous RED)
+ - Assertions confirm no wrap/update_grant occurs on the fail-closed path
+
+ Failing tests pin the mismatch and pin-absent fail-closed behavior; the match case is preserved.
+
+
+
+ Task 2: GREEN — get_recipient_pubkey_pins seam + fail-closed compare before wrap_key
+ crates/sdk/src/rotation/engine.rs, crates/fuse/src/write_ops/rotation_deps.rs
+
+ - crates/sdk/src/rotation/engine.rs — RotationDeps trait (146), query_grants_rooted_at default (180), re_mint_grants_rooted_at (597-626), the RotateFailed(format!(...)) error convention (610-615)
+ - crates/fuse/src/write_ops/rotation_deps.rs — FuseRotationDeps/ApiClientTransport (holds &inodes); Inode.node_id + the InodeKind recipient_pins cache (80-05); the find_map-by-id lookup idiom (582-599)
+
+
+ - `RotationDeps::get_recipient_pubkey_pins(&self, node_id: &str) -> Result>, RotationError>` (no permissive default that silently returns empty — implementors must provide it; the FuseRotationDeps impl reads the InodeTable pin cache for the inode whose node_id matches).
+ - `re_mint_grants_rooted_at` fetches the pin list once for `node_id`, then for each non-revoked grant compares grant.recipient_public_key (normalized to raw bytes) against the pins; on a non-member OR an empty pin list it returns Err(RotateFailed) BEFORE wrap_key (aborting the node's re-mint), using the existing RotateFailed message convention.
+
+
+ Add `get_recipient_pubkey_pins` to the RotationDeps trait and implement it on FuseRotationDeps by looking
+ up the inode whose node_id matches and returning its cached recipient_pins (mirror find_grant_root_state's
+ find_map). In re_mint_grants_rooted_at, fetch the pins for node_id once, then insert a fail-closed compare
+ immediately before the wrap_key at :610: RotateFailed on empty pins (D-03e) and on a non-member recipient.
+ Keep the is_revoked delete branch unchanged. Do NOT modify rotateWriteFromNode (~:2762). Normalize both
+ sides to raw bytes before comparing (PATTERNS 0x-strip idiom).
+
+
+ cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8; cargo test -p cipherbox-sdk rotation 2>&1 | tail -8
+
+
+ - `grep -n "get_recipient_pubkey_pins" crates/sdk/src/rotation/engine.rs crates/fuse/src/write_ops/rotation_deps.rs` shows the trait method + FuseRotationDeps impl
+ - The compare sits immediately before `wrap_key(new_read_key, &grant.recipient_public_key)` at engine.rs:610 and returns RotateFailed on empty pins and on non-member
+ - Tests A/B/C pass; the is_revoked branch and existing re-mint success path are unchanged
+ - `grep -n "rotateWriteFromNode\|2762" crates/sdk/src/rotation/engine.rs` — the co-writer re-wrap site is NOT modified (recorded as out-of-scope follow-up)
+ - `cargo test -p cipherbox-fuse -p cipherbox-sdk` green
+
+ Rust re-mint verifies the recipient against the owner-sealed pin offline and fails closed on mismatch/absent, without touching the out-of-scope co-writer site.
+
+
+
+
+
+- `RotationDeps::get_recipient_pubkey_pins(node_id)` trait method + FuseRotationDeps impl (offline InodeTable read)
+- Fail-closed pin compare before wrap_key in re_mint_grants_rooted_at (engine.rs)
+- OUT-OF-SCOPE follow-up recorded: rotateWriteFromNode co-writer re-wrap (engine.rs ~:2762) still trusts the server pubkey — surface as a phase-owner assumption / follow-up todo, NOT implemented here (A3 / Open Question 2)
+- Pre-ship note: tests/sdk-e2e (live client→API IPNS round-trip) must pass before ship — this is a key-lifecycle change
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| CipherBox relay → owner re-mint (GET /shares/sent) | recipient_public_key round-trips through the untrusted relay |
+| owner-sealed write-body pin → re-mint wrap decision | The pin (not the relay pubkey) is the authorization anchor |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-15 | Spoofing / Tampering | relay substitutes recipient_public_key at re-mint | critical | mitigate | Fail-closed compare against the owner-sealed pin before wrap_key (D-03d consumer 1) |
+| T-80-16 | Elevation of Privilege | empty/absent pin treated as pass | high | mitigate | Empty pin list = hard RotateFailed (D-03e no-legacy) |
+| T-80-17 | Spoofing / Tampering | co-writer re-wrap (rotateWriteFromNode) still server-trusted | medium | accept | Out of scope for Phase 80 (CONTEXT names 3 consumers); recorded as a follow-up todo for the phase owner |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `cargo test -p cipherbox-fuse -p cipherbox-sdk` green (mismatch/absent fail-closed; match preserved)
+- rotateWriteFromNode co-writer site untouched (grep confirms)
+- Pin sourced offline from the InodeTable cache (no extra /shares/sent fetch introduced)
+- Pre-ship: tests/sdk-e2e live round-trip must be green before /gsd-verify-work
+
+
+
+The Rust FUSE re-mint binds the new read key only to a recipient pubkey pinned in the node's owner-sealed
+write-body, fails closed on mismatch or absent pin (D-03e), resolves pins offline, and leaves the
+out-of-scope co-writer re-wrap site untouched.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md
new file mode 100644
index 0000000000..4bb1ca94dc
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md
@@ -0,0 +1,159 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 06
+subsystem: infra
+tags: [rust, fuse, rotation, ecies, recipient-pins, fail-closed, sharing]
+
+# Dependency graph
+requires:
+ - phase: 80-01
+ provides: NodeWriteBody.recipient_pins field (owner-sealed pin list)
+ - phase: 80-05
+ provides: InodeTable recipient_pins cache + FuseRotationDeps pin surfacing groundwork
+provides:
+ - "RotationDeps::get_recipient_pubkey_pins seam (required, no permissive default)"
+ - "FuseRotationDeps + ApiClientTransport offline pin resolution from the InodeTable cache"
+ - "Fail-closed recipient-pin compare before wrap_key in re_mint_grants_rooted_at (D-03d)"
+ - "Pin-absent hard fail-closed at re-mint (D-03e no-legacy)"
+affects: [rotation, sharing, re-mint, D-03d-consumer-2-typescript, D-03d-consumer-3]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Fail-closed pin binding: verify grant.recipient_public_key against the node's OWN owner-sealed pins before ECIES-wrapping a rotated read key; abort the whole node's re-mint (not a per-grant skip) on mismatch or empty pins"
+ - "Offline authorization anchor via the RotationTransport seam reading the in-memory InodeTable pin cache (no extra network fetch)"
+
+key-files:
+ created: []
+ modified:
+ - crates/sdk/src/rotation/engine.rs
+ - crates/fuse/src/write_ops/rotation_deps.rs
+
+key-decisions:
+ - "get_recipient_pubkey_pins is a REQUIRED trait method (no default) so a relay-substituted recipient can never slip through an implementor that forgot to wire the pin source"
+ - "FuseRotationDeps resolves pins through the existing RotationTransport seam (ApiClientTransport reads the InodeTable pin cache offline), mirroring query_grants_rooted_at — not by holding an InodeTable directly"
+ - "Empty pin list is a legitimate method return; the CALLER (re_mint) treats empty-at-re-mint as the D-03e hard fail-closed, keeping the method free of policy"
+ - "Raw-byte equality compare — both pins (base64-decoded) and grant key (0x-stripped + hex-decoded) are already normalized to raw ECIES pubkey bytes at their decode boundaries (PATTERNS straight-equality idiom)"
+
+patterns-established:
+ - "Pattern 1: authorization anchor = node's own owner-sealed write-body pin, never the relay-supplied /shares/sent pubkey"
+ - "Pattern 2: fail-closed compare aborts the whole node's re-mint (RotateFailed), never a per-grant skip-and-continue like the is_revoked branch"
+
+requirements-completed:
+ - "SC2 / D-03d (consumer 1 of 3): Rust re-mint verifies grant.recipient_public_key against the node's owner-sealed pin before wrap_key, fail-closed on mismatch"
+ - "SC2 / D-03e: pin absent at re-mint is a hard fail-closed invariant violation (no-legacy, no TOFU, no backfill)"
+
+coverage:
+ - id: D1
+ description: "re_mint_grants_rooted_at fails the whole node's re-mint closed when grant.recipient_public_key is not among the node's owner-sealed pins (relay-substituted recipient)"
+ requirement: "SC2 / D-03d (consumer 1 of 3)"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#re_mint_fails_closed_when_recipient_is_not_pinned"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "An absent/empty pin list at re-mint is a hard RotateFailed (D-03e no-legacy), not a silent skip"
+ requirement: "SC2 / D-03e"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#re_mint_fails_closed_when_pin_list_is_empty"
+ status: pass
+ human_judgment: false
+ - id: D3
+ description: "A pinned recipient re-mints exactly once — the pre-80-06 success path is preserved"
+ requirement: "SC2 / D-03d (consumer 1 of 3)"
+ verification:
+ - kind: unit
+ ref: "crates/fuse/src/write_ops/rotation_deps.rs#re_mint_succeeds_when_recipient_is_pinned"
+ status: pass
+ - kind: unit
+ ref: "crates/sdk/src/rotation/engine.rs#high3_inner_grant_at_a_child_is_re_minted_and_revoked_recipient_is_cut"
+ status: pass
+ human_judgment: false
+ - id: D4
+ description: "get_recipient_pubkey_pins seam on FuseRotationDeps/ApiClientTransport resolves the pin list OFFLINE from the InodeTable pin cache (no extra network fetch)"
+ requirement: "SC2 / D-03d (consumer 1 of 3)"
+ verification:
+ - kind: unit
+ ref: "cargo test -p cipherbox-fuse rotation_deps (17 passed) + cargo build -p cipherbox-fuse"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 18min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 06: Rust Re-Mint Fail-Closed Recipient-Pin Binding Summary
+
+**Rust FUSE re-mint now verifies `grant.recipient_public_key` against the node's OWN owner-sealed recipient pins (read offline from the InodeTable cache) before ECIES-wrapping the rotated read key, and fails the whole node's re-mint closed on a non-member (D-03d) or an absent/empty pin list (D-03e).**
+
+## Performance
+
+- **Duration:** 18 min
+- **Started:** 2026-07-12
+- **Completed:** 2026-07-12
+- **Tasks:** 2 (TDD RED + GREEN)
+- **Files modified:** 2
+
+## Accomplishments
+- Added `RotationDeps::get_recipient_pubkey_pins(node_id)` as a REQUIRED trait method (no permissive default), plus the matching `RotationTransport` seam method — so no implementor can silently trust a relay-substituted recipient.
+- Implemented offline pin resolution: `FuseRotationDeps` delegates through the transport seam; `ApiClientTransport::get_recipient_pubkey_pins` reads the already-materialized `InodeTable` pin cache (80-05) via a new `find_recipient_pins` find_map — no extra `GET /shares/sent` or network fetch (D-03a).
+- Inserted the fail-closed compare immediately before `wrap_key(new_read_key, &grant.recipient_public_key)` in `re_mint_grants_rooted_at`: a non-member recipient OR an empty/absent pin list returns `RotateFailed`, aborting the WHOLE node's re-mint (never a per-grant skip-and-continue).
+- Added the two mandated negative tests (pin-mismatch fail-closed, pin-absent fail-closed) plus the positive match test; both negatives are non-vacuous RED (they failed against pre-change code) and now green.
+
+## Task Commits
+
+Single commit (per execution constraint — SUMMARY committed alongside code):
+
+1. **Task 1+2 (TDD RED→GREEN): fail-closed recipient-pin binding at Rust re-mint** — see commit below (feat)
+
+## Files Created/Modified
+- `crates/sdk/src/rotation/engine.rs` — new required `RotationDeps::get_recipient_pubkey_pins` trait method; `recipient_is_pinned` helper; fail-closed compare in `re_mint_grants_rooted_at` before `wrap_key`; `FakeDeps` pin fixture (`pins_by_node` + `seed_pins` + impl); updated the existing `high3_inner_grant_...` test to pin the surviving recipient.
+- `crates/fuse/src/write_ops/rotation_deps.rs` — `RotationTransport::get_recipient_pubkey_pins`; `FuseRotationDeps` delegate impl; `ApiClientTransport` offline impl + `find_recipient_pins`; `FakeTransport` pin fixture (`pins_by_node` + `seed_pins` + impl); Tests A/B/C.
+
+## Decisions Made
+- **Required trait method, no default:** a permissive empty-returning default would defeat the entire mitigation on a mis-wired implementor (T-80-15). The empty list is a legitimate value; only the caller decides it is a hard fail (D-03e), keeping the seam policy-free.
+- **Delegate through the transport seam** rather than giving `FuseRotationDeps` an `InodeTable` handle — `FuseRotationDeps` never held one, and `ApiClientTransport` already owns `&inodes`. This mirrors `query_grants_rooted_at` exactly and keeps resolution offline.
+- **Raw-byte equality compare:** both sides are normalized to raw ECIES pubkey bytes at their decode boundaries, so the D-03d check is a straight `==` with no 0x/hex mismatch (PATTERNS idiom).
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Updated the existing engine re-mint success test to seed a pin**
+- **Found during:** Task 2 (GREEN)
+- **Issue:** Making `re_mint` fail-closed on unpinned recipients broke the pre-existing `high3_inner_grant_at_a_child_is_re_minted_and_revoked_recipient_is_cut` test, whose surviving recipient had no pin seeded (empty pins → new D-03e hard fail).
+- **Fix:** Seeded the node's owner-sealed pin list with the active recipient's pubkey (`deps.seed_pins(&child_uuid(0), vec![active_pk...])`); the revoked recipient needs no pin (deleted before any pin check). This is the correct post-change behavior — the survivor IS legitimately pinned.
+- **Files modified:** crates/sdk/src/rotation/engine.rs
+- **Verification:** `cargo test -p cipherbox-sdk rotation` — 54 passed.
+- **Committed in:** part of the plan commit.
+
+---
+
+**Total deviations:** 1 auto-fixed (1 blocking)
+**Impact on plan:** Necessary to keep the existing re-mint success path green under the new fail-closed invariant. No scope creep — same recipient, now explicitly pinned. engine.rs was already in the plan's `files_modified`.
+
+## Issues Encountered
+None.
+
+## Out-of-Scope / Follow-ups
+- **D-03d consumer 3 (co-writer re-wrap):** the 4th co-writer re-wrap site (TS `rotateWriteFromNode`, the write-revocation path) still trusts the server-supplied pubkey. CONTEXT names exactly 3 consumers; this write-revocation site is OUT OF SCOPE for this plan and recorded as a phase-owner follow-up (RESEARCH Open Question 2 / A3, threat T-80-17 disposition = accept). Confirmed no `rotate_write_from_node`/`rotateWriteFromNode` symbol exists in `crates/sdk/src/rotation/engine.rs` and it was not touched.
+- **Pre-ship:** `tests/sdk-e2e` (live client→API IPNS round-trip) must be green before ship — this is a key-lifecycle change. NOT run here per scoped-tests constraint.
+
+## Verification (scoped)
+- `cargo test -p cipherbox-sdk rotation` → **test result: ok. 54 passed; 0 failed; 99 filtered out**
+- `cargo test -p cipherbox-fuse rotation_deps` → **test result: ok. 17 passed; 0 failed; 112 filtered out** (includes the 3 new pin tests A/B/C; pin-mismatch and pin-absent are the mandated negatives)
+- `cargo build -p cipherbox-sdk -p cipherbox-fuse` → Finished (only upstream `fuser` dep warnings)
+- RED proof (pre-GREEN): `re_mint_fails_closed_when_recipient_is_not_pinned` and `re_mint_fails_closed_when_pin_list_is_empty` both FAILED against pre-change code (non-vacuous).
+
+## Next Phase Readiness
+- D-03d consumer 1 (Rust re-mint) is complete and fail-closed. Consumers 2 (TypeScript re-mint) and 3 remain for their own plans.
+- Co-writer re-wrap follow-up recorded above for the phase owner.
+
+---
+*Phase: 80-rotation-write-plane-and-re-mint-durability*
+*Completed: 2026-07-12*
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-PLAN.md
new file mode 100644
index 0000000000..8bf30e2340
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-PLAN.md
@@ -0,0 +1,203 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 07
+type: tdd
+wave: 3
+depends_on: ["80-01", "80-03", "80-04"]
+files_modified:
+ - packages/sdk-core/src/rotation/engine.ts
+ - packages/sdk/src/share/owner-reconcile.ts
+ - packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts
+ - packages/sdk/src/__tests__/owner-reconcile.test.ts
+autonomous: true
+requirements:
+ - "SC2 / D-03d (consumer 2 of 3): TS re-mint verifies grant.recipientPublicKey against the node's owner-sealed pin before wrapKey, fail-closed on mismatch"
+ - "SC2 / D-03e: pin absent at TS re-mint is a hard fail-closed invariant violation"
+user_setup: []
+
+must_haves:
+ truths:
+ - "reMintGrantsRootedAt fetches the node's recipient pins via a getPinsFn seam and throws (fail-closed) if grant.recipientPublicKey is not pinned (D-03d)"
+ - "An absent/empty pin list at TS re-mint throws (D-03e no-legacy), it does not skip-and-continue"
+ - "buildGrantRemintCallbacks wires getPinsFn to the client's getRecipientPubkeyPins read path (80-04)"
+ artifacts:
+ - "packages/sdk-core/src/rotation/engine.ts — GrantRemintCallbacks.getPinsFn + fail-closed assertRecipientPinned before wrapKey"
+ - "packages/sdk/src/share/owner-reconcile.ts — getPinsFn wired via getRecipientPubkeyPins in buildGrantRemintCallbacks"
+ - "grant-remint.test.ts + owner-reconcile.test.ts — mismatch/absent fail-closed cases"
+ key_links:
+ - "assertRecipientPinned (from 80-04) is called immediately before wrapKey(newReadKey, grant.recipientPublicKey) at engine.ts:587"
+ - "getPinsFn resolves the node's owner-sealed recipientPins (via getRecipientPubkeyPins), NOT the /shares/sent recipientPublicKey"
+ prohibitions:
+ - "MUST NOT model a pin mismatch as a per-grant skip like the isRevoked branch — it throws and aborts the node's re-mint (D-03e, Pitfall 5)"
+ - "MUST NOT reimplement the compare in the web layer — reuse the sdk-core assertRecipientPinned helper (80-04); web (80-08) also reuses it"
+ - "MUST NOT trust the server-fed recipientPublicKey as the pin source"
+ - "MUST NOT add an API/DTO change or run api:generate (D-03f)"
+---
+
+
+Consumer 2 of D-03d's three fail-closed enforcement sites: the TS re-mint. `reMintGrantsRootedAt`
+(sdk-core engine.ts) calls `wrapKey(newReadKey, grant.recipientPublicKey)` at :587 where
+`grant.recipientPublicKey` came back through the relay via `listSentGrants()`. Insert a fail-closed
+`assertRecipientPinned` (from 80-04) before the wrap, sourcing the pin list from the node's owner-sealed
+`recipientPins` via a new `getPinsFn` seam on `GrantRemintCallbacks`, wired in `buildGrantRemintCallbacks`
+(owner-reconcile.ts) to the client's `getRecipientPubkeyPins` read path. Absent/empty pins throw (D-03e).
+
+Depends on 80-01 (field), 80-03 (engine.ts/owner-reconcile.ts sequencing), and 80-04
+(assertRecipientPinned + getRecipientPubkeyPins). engine.ts/owner-reconcile.ts overlap 80-03 and dep 80-04
+(wave 2) → wave 3.
+
+Purpose: stop the TS re-mint from wrapping the read key to a relay-substituted recipient.
+Output: getPinsFn seam + fail-closed compare + mismatch/absent tests.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@packages/sdk-core/src/rotation/engine.ts
+@packages/sdk/src/share/owner-reconcile.ts
+@packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts
+
+
+
+
+
+ Task 1: RED — pin-mismatch and pin-absent fail-closed tests for reMintGrantsRootedAt
+ packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts
+
+ - packages/sdk-core/src/rotation/engine.ts — `GrantRemintCallbacks` (lines 57-84, `queryGrantsFn` grant shape at :69-72), `reMintGrantsRootedAt` (563-590) with `wrapKey(newReadKey, grant.recipientPublicKey)` at :587 and the isRevoked→deleteGrantFn branch; the throw style at ~:2764 (`new Error(..., { cause })`)
+ - packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts — existing seam-test harness/mocks to extend
+ - packages/sdk-core/src/share/recipient-pins.ts — `assertRecipientPinned` (from 80-04)
+
+
+ - Test A (mismatch): reMintGrantsRootedAt with a getPinsFn returning pins that do NOT include grant.recipientPublicKey throws and does not call updateGrantFn/wrapKey for that grant.
+ - Test B (absent): getPinsFn returns an empty list → throws (D-03e).
+ - Test C (match): pins include the grant pubkey → proceeds and wraps as before.
+ - A/B fail RED (no getPinsFn/compare yet); C passes today and must stay passing.
+
+
+ Extend grant-remint.test.ts with a `getPinsFn` mock and Tests A/B/C, asserting the mismatch/absent cases
+ throw and skip the wrap. Do NOT implement the seam/compare yet.
+
+
+ pnpm --filter @cipherbox/sdk-core test grant-remint 2>&1 | grep -qi "fail" && echo "RED confirmed"
+
+
+ - grant-remint.test.ts references a getPinsFn mock and assertRecipientPinned behavior
+ - A (mismatch) and B (absent) FAIL against current code (non-vacuous RED)
+ - The match case (C) is asserted to still wrap
+
+ Failing tests pin the TS mismatch/absent fail-closed behavior.
+
+
+
+ Task 2: GREEN — getPinsFn seam + assertRecipientPinned before wrapKey (sdk-core)
+ packages/sdk-core/src/rotation/engine.ts
+
+ - packages/sdk-core/src/rotation/engine.ts — GrantRemintCallbacks (57-84), reMintGrantsRootedAt (563-590), wrap at :587
+ - packages/sdk-core/src/share/recipient-pins.ts — assertRecipientPinned (80-04)
+
+
+ - `GrantRemintCallbacks.getPinsFn?: (nodeId: string) => Promise` added.
+ - reMintGrantsRootedAt fetches pins once for nodeId, then for each non-revoked grant calls
+ assertRecipientPinned(grant.recipientPublicKey, pins) BEFORE wrapKey; a throw aborts the node's re-mint.
+ - Absent getPinsFn OR empty pins → throw (D-03e). isRevoked branch unchanged.
+
+
+ Add `getPinsFn` to `GrantRemintCallbacks`. In reMintGrantsRootedAt, resolve the pin list for nodeId once
+ (throw if getPinsFn is missing — this is a required seam in the enforced path), then call
+ assertRecipientPinned immediately before the wrapKey at :587. Reuse the file's existing throw style. Do
+ NOT modify the isRevoked/deleteGrantFn branch. No api:generate.
+
+
+ pnpm --filter @cipherbox/sdk-core test grant-remint 2>&1 | tail -12
+
+
+ - `grep -n "getPinsFn\|assertRecipientPinned" packages/sdk-core/src/rotation/engine.ts` shows the seam + compare before wrapKey
+ - Tests A/B/C pass; the isRevoked branch is unchanged
+ - `pnpm --filter @cipherbox/sdk-core typecheck` passes
+
+ TS re-mint verifies the recipient against the owner-sealed pin and fails closed on mismatch/absent.
+
+
+
+ Task 3: GREEN — wire getPinsFn to getRecipientPubkeyPins in buildGrantRemintCallbacks
+ packages/sdk/src/share/owner-reconcile.ts, packages/sdk/src/__tests__/owner-reconcile.test.ts
+
+ - packages/sdk/src/share/owner-reconcile.ts — `buildGrantRemintCallbacks` (66-84, the closure-scoped listSentGrants cache from 80-03), `runOwnerReconcile` (94-104)
+ - packages/sdk/src/client.ts — `getRecipientPubkeyPins` (from 80-04)
+ - packages/sdk/src/__tests__/owner-reconcile.test.ts — existing harness to extend
+
+
+ - `buildGrantRemintCallbacks` returns callbacks including `getPinsFn(nodeId)` that resolves the node's
+ owner-sealed recipientPins via getRecipientPubkeyPins (client/transport), so the enforced re-mint path
+ has a real pin source end-to-end.
+ - An owner-reconcile test drives a mismatch and asserts the reconcile pass throws (fail-closed).
+
+
+ Add `getPinsFn` to the callbacks built by `buildGrantRemintCallbacks`, delegating to the client's
+ getRecipientPubkeyPins read path (keep the 80-03 listSentGrants memo intact). Extend owner-reconcile.test.ts
+ with an end-to-end mismatch case asserting runOwnerReconcile fails closed. No api:generate.
+
+
+ pnpm --filter @cipherbox/sdk test owner-reconcile 2>&1 | tail -12
+
+
+ - `grep -n "getPinsFn\|getRecipientPubkeyPins" packages/sdk/src/share/owner-reconcile.ts` shows the seam wired to the read path
+ - The 80-03 listSentGrants memo is preserved (grep shows the cached closure still present)
+ - owner-reconcile.test.ts asserts a mismatch fails the reconcile pass closed
+ - `pnpm --filter @cipherbox/sdk test owner-reconcile` green
+
+ The TS owner-reconcile re-mint path has an end-to-end pin source and fails closed on relay substitution.
+
+
+
+
+
+- `GrantRemintCallbacks.getPinsFn` seam (engine.ts)
+- Fail-closed assertRecipientPinned compare before wrapKey in reMintGrantsRootedAt
+- getPinsFn wired via getRecipientPubkeyPins in buildGrantRemintCallbacks (owner-reconcile.ts)
+- Pre-ship note: tests/sdk-e2e must pass before ship (key-lifecycle change)
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| CipherBox relay → TS owner re-mint (listSentGrants) | recipientPublicKey round-trips through the untrusted relay |
+| owner-sealed write-body pin → re-mint wrap decision | The pin (not the relay pubkey) authorizes the wrap |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-18 | Spoofing / Tampering | relay substitutes recipientPublicKey at TS re-mint | critical | mitigate | Fail-closed assertRecipientPinned before wrapKey (D-03d consumer 2) |
+| T-80-19 | Elevation of Privilege | empty/absent pin treated as pass | high | mitigate | Empty/absent pin list throws (D-03e no-legacy) |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `pnpm --filter @cipherbox/sdk-core test grant-remint` green (mismatch/absent fail-closed; match preserved)
+- `pnpm --filter @cipherbox/sdk test owner-reconcile` green (end-to-end fail-closed; 80-03 memo intact)
+- `pnpm --filter @cipherbox/sdk-core typecheck` green; no api-client changes
+- Pre-ship: tests/sdk-e2e live round-trip green before /gsd-verify-work
+
+
+
+The TS re-mint binds the new read key only to a recipient pubkey pinned in the node's owner-sealed
+write-body, fails closed on mismatch or absent pin (D-03e), and sources pins via the client read path — with
+no API/DB change.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md
new file mode 100644
index 0000000000..a345cb000e
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md
@@ -0,0 +1,154 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 07
+subsystem: crypto
+tags: [rotation, re-mint, recipient-pins, fail-closed, sdk-core, owner-reconcile, ECIES]
+
+# Dependency graph
+requires:
+ - phase: 80-01
+ provides: recipientPins field on NodeWriteBody wire codec
+ - phase: 80-03
+ provides: engine.ts/owner-reconcile.ts sequencing + closure-scoped listSentGrants memo
+ - phase: 80-04
+ provides: assertRecipientPinned helper + client getRecipientPubkeyPins read path
+provides:
+ - GrantRemintCallbacks.getPinsFn seam on reMintGrantsRootedAt (sdk-core)
+ - Fail-closed assertRecipientPinned verification before wrapKey in the TS re-mint
+ - getPinsFn wired via transport.getRecipientPubkeyPins in buildGrantRemintCallbacks (sdk)
+affects: [80-08, ship, verify-work]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Fail-closed recipient-pin verification at a wrap site (D-03d consumer 2 of 3)"
+ - "Reuse the shared sdk-core assertRecipientPinned helper across all enforcement consumers"
+
+key-files:
+ created: []
+ modified:
+ - packages/sdk-core/src/rotation/engine.ts
+ - packages/sdk/src/share/owner-reconcile.ts
+ - packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts
+ - packages/sdk/src/__tests__/owner-reconcile.test.ts
+
+key-decisions:
+ - "getPinsFn is optional on GrantRemintCallbacks but REQUIRED on the enforced (surviving-grant) path — absent seam throws (D-03e)"
+ - "Pins fetched once per node, only when at least one surviving grant exists — an all-revoked node needs no pin source, preserving existing revoked-only callers"
+ - "transport.getRecipientPubkeyPins kept OPTIONAL on OwnerReconcileTransport so the web wrapper (80-08) wires it separately; absent method fails closed, not open"
+
+patterns-established:
+ - "Normalize Uint8Array pins to base64 before assertRecipientPinned (its stored-pin encoding)"
+ - "Enforcement is a hard throw that aborts the node's re-mint — NOT a per-grant skip like isRevoked"
+
+requirements-completed:
+ - "SC2 / D-03d (consumer 2 of 3): TS re-mint verifies grant.recipientPublicKey against the node's owner-sealed pin before wrapKey, fail-closed on mismatch"
+ - "SC2 / D-03e: pin absent at TS re-mint is a hard fail-closed invariant violation"
+
+coverage:
+ - id: D1
+ description: "TS re-mint fails closed when the relay-fed recipientPublicKey is not in the node's owner-sealed pin list (D-03d, T-80-18)"
+ requirement: "SC2 / D-03d (consumer 2 of 3): TS re-mint verifies grant.recipientPublicKey against the node's owner-sealed pin before wrapKey, fail-closed on mismatch"
+ verification:
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test A (D-03d mismatch): throws and does NOT wrap when getPinsFn omits the grant recipient"
+ status: pass
+ - kind: unit
+ ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 5 (D-03d mismatch): reconcile fails closed when the pin list omits the surviving grant recipient"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "Absent/empty pin list at TS re-mint is a hard fail-closed error, never a skip (D-03e, T-80-19)"
+ requirement: "SC2 / D-03e: pin absent at TS re-mint is a hard fail-closed invariant violation"
+ verification:
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test B (D-03e absent): throws when getPinsFn returns an empty pin list"
+ status: pass
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test B2 (D-03e absent seam): throws when getPinsFn is missing for a surviving grant"
+ status: pass
+ - kind: unit
+ ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 6 (D-03e absent): reconcile fails closed when the pin list is empty"
+ status: pass
+ human_judgment: false
+ - id: D3
+ description: "getPinsFn seam sources pins from the client read path (getRecipientPubkeyPins); a pinned recipient wraps as before, 80-03 listSentGrants memo intact"
+ verification:
+ - kind: unit
+ ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 7 (pin source): getPinsFn resolves via getRecipientPubkeyPins, matching pin wraps as before"
+ status: pass
+ - kind: unit
+ ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test C (match): proceeds and wraps when getPinsFn includes the grant recipient"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 18min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 07: TS Re-mint Recipient-Pin Fail-Closed Enforcement Summary
+
+**The TS owner re-mint now verifies each surviving grant's relay-round-tripped recipientPublicKey against the node's owner-sealed recipientPins (via a new getPinsFn seam reusing sdk-core's assertRecipientPinned) before wrapKey, and fails closed on mismatch or absent pins.**
+
+## Performance
+
+- **Duration:** ~18 min
+- **Started:** 2026-07-12T20:47Z
+- **Completed:** 2026-07-12T20:52Z
+- **Tasks:** 3
+- **Files modified:** 4
+
+## Accomplishments
+- Added `GrantRemintCallbacks.getPinsFn` seam to `reMintGrantsRootedAt` (sdk-core engine.ts) that resolves the node's owner-sealed pins.
+- Inserted a fail-closed `assertRecipientPinned` (reused from 80-04) immediately before `wrapKey(newReadKey, grant.recipientPublicKey)` — a mismatch or absent/empty pin list throws and aborts the node's re-mint (NOT a per-grant skip like isRevoked).
+- Wired `getPinsFn` to the client `getRecipientPubkeyPins` read path via `transport.getRecipientPubkeyPins` in `buildGrantRemintCallbacks` (sdk owner-reconcile.ts), preserving the 80-03 closure-scoped `listSentGrants` memo.
+- Added mismatch + absent negative tests at both layers (sdk-core unit and sdk end-to-end).
+
+## Task Commits
+
+Executed as a single atomic commit per D-03d consumer-2 scope (TDD RED→GREEN across two packages):
+
+1. **Tasks 1-3: RED tests + getPinsFn seam + owner-reconcile wiring** - see plan metadata commit below
+
+**Plan metadata + code:** committed together (feat)
+
+_All four files (engine, owner-reconcile, and both test files) plus this SUMMARY landed in one commit._
+
+## Files Created/Modified
+- `packages/sdk-core/src/rotation/engine.ts` - `GrantRemintCallbacks.getPinsFn` seam + fail-closed `assertRecipientPinned` before `wrapKey`; pins fetched once per node only when a surviving grant exists.
+- `packages/sdk/src/share/owner-reconcile.ts` - optional `getRecipientPubkeyPins` on `OwnerReconcileTransport` + `getPinsFn` delegating to it (fail-closed if absent); 80-03 memo untouched.
+- `packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts` - Tests A (mismatch), B (empty), B2 (missing seam), C (match); Tests 1/3 updated to supply a matching `getPinsFn`.
+- `packages/sdk/src/__tests__/owner-reconcile.test.ts` - crypto mock switched to `importOriginal` (keeps real base64/hex codecs for `assertRecipientPinned`); `makeTransport` supplies `getRecipientPubkeyPins`; Tests 5 (mismatch), 6 (empty), 7 (pin source) added.
+
+## Decisions Made
+- **getPinsFn optional in the type, required on the enforced path:** the type stays optional so all-revoked callers and the no-callbacks no-op keep compiling, but any surviving grant with a missing seam throws (D-03e). This keeps the existing revoked-only test (Test 2) green without a getPinsFn.
+- **Pins fetched once, gated on `grants.some(!isRevoked)`:** an all-revoked node performs no wrap and needs no pin source, so the seam is only demanded when enforcement actually applies.
+- **`transport.getRecipientPubkeyPins` kept optional:** the concrete web wrapper (`apps/web/.../owner-reconcile.service.ts`) is wired in 80-08; leaving it optional keeps the web package compiling now and makes the web re-mint fail closed (throw, caught+logged) until 80-08 completes the wiring — the safe direction.
+
+## Deviations from Plan
+None - plan executed exactly as written. Added one extra sdk-core test (B2: missing-seam throws) beyond the plan's A/B/C to explicitly cover the "absent getPinsFn" D-03e path, and one extra sdk test (Test 7: pin-source-resolves) to assert the getRecipientPubkeyPins wiring — both strengthen coverage without changing scope.
+
+## Issues Encountered
+- Bash cwd drifted to the primary checkout (main) between calls; re-targeted every command at the worktree path explicitly. The initial "4 tests passed" was the primary checkout running stale code — re-running inside the worktree correctly showed 4 failing RED tests.
+- The sdk test resolves `@cipherbox/sdk-core` to its built dist, so `assertRecipientPinned` needs the real base64/hex codecs. Switched the owner-reconcile crypto mock to `importOriginal` (keeping only the ECIES/randomness stubs) and rebuilt sdk-core dist before running the sdk suite.
+
+## User Setup Required
+None - no external service configuration required. No API/DTO change, no api:generate, no DB migration.
+
+## Next Phase Readiness
+- 80-08 (web consumer 3 of 3) wires `getRecipientPubkeyPins` on the concrete web `OwnerReconcileTransport` (delegating to `client.getRecipientPubkeyPins`) and reuses the same `assertRecipientPinned` compare — the seam and helper are in place.
+- Pre-ship: run the full sdk-core/sdk suites + sdk-e2e live round-trip before `/gsd-verify-work` (key-lifecycle change).
+
+## Verification Results
+- `pnpm --filter @cipherbox/sdk-core test grant-remint` → 8 passed (8)
+- `pnpm --filter @cipherbox/sdk test owner-reconcile` → 9 passed (9)
+- `pnpm --filter @cipherbox/sdk-core typecheck` → clean
+- `pnpm --filter @cipherbox/sdk typecheck` → clean
+- eslint + prettier on all 4 touched files → clean
+
+---
+*Phase: 80-rotation-write-plane-and-re-mint-durability*
+*Completed: 2026-07-12*
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-PLAN.md
new file mode 100644
index 0000000000..e2f1e55b10
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-PLAN.md
@@ -0,0 +1,186 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 08
+type: execute
+wave: 4
+depends_on: ["80-04", "80-07"]
+files_modified:
+ - apps/web/src/components/file-browser/ShareDialog.tsx
+ - apps/web/src/services/owner-reconcile.service.ts
+autonomous: true
+requirements:
+ - "SC2 / D-03c (web issuance): ShareDialog writes the pasted recipient pubkey into the shared node's owner-sealed write-body pin list at grant creation"
+ - "SC2 / D-03d (consumer 3 of 3): the web upgrade path verifies the server-fed recipient pubkey against the pin before re-wrapping, and the web owner-reconcile path delegates to the enforced runOwnerReconcile"
+user_setup: []
+
+must_haves:
+ truths:
+ - "On share creation, ShareDialog.handleShare calls client.addRecipientPubkeyPin so the recipient pubkey is committed to the node's owner-sealed write-body (D-03c)"
+ - "ShareDialog.handleUpgrade calls assertRecipientPinned(server-fed recipientPublicKey, getRecipientPubkeyPins) before resolveShareEncryptedWriteKey re-wraps — fail-closed on mismatch/absent (D-03d)"
+ - "owner-reconcile.service.ts's runOwnerReconcile path carries the pin enforcement from 80-07 (getPinsFn), verified end-to-end"
+ artifacts:
+ - "apps/web/src/components/file-browser/ShareDialog.tsx — issuance pin write (handleShare) + upgrade-path fail-closed compare (handleUpgrade)"
+ - "apps/web/src/services/owner-reconcile.service.ts — pin enforcement flows through runOwnerReconcile (80-07); wiring verified"
+ key_links:
+ - "Issuance-time wrapKey at :184/:205 stays EXEMPT (the pin does not exist until handleShare writes it); the compare belongs only at the upgrade/reconcile re-wrap (:306) and the runOwnerReconcile path"
+ - "Web reuses sdk-core assertRecipientPinned + client.getRecipientPubkeyPins/addRecipientPubkeyPin (80-04) — no compare reimplemented in the web layer"
+ prohibitions:
+ - "MUST NOT reimplement the pin compare in the web layer — call the sdk-core assertRecipientPinned helper (80-04)"
+ - "MUST NOT pin-check the issuance-time wrap (handleShare :184/:205) — that is where the pin is first written (D-03c)"
+ - "MUST NOT add an API/DTO change or run api:generate (D-03f)"
+ - "MUST NOT trust share.recipientPublicKey (server-fed store) at the upgrade/reconcile re-wrap without a pin compare"
+---
+
+
+Consumer 3 of D-03d plus the D-03c web issuance write. `ShareDialog.tsx` today (a) creates a share and
+ECIES-wraps the read/name/write key to the pasted recipient pubkey at issuance (handleShare :184/:205),
+and (b) on a read→write upgrade re-wraps to `share.recipientPublicKey` read straight from the server-fed
+store (handleUpgrade :297-306) with NO pin check. `owner-reconcile.service.ts` re-mints via
+`runOwnerReconcile` (enforced by 80-07's getPinsFn seam).
+
+This plan:
+1. **D-03c issuance:** in handleShare, after the share is created, write the recipient pubkey into the
+ node's owner-sealed write-body via `client.addRecipientPubkeyPin` (80-04) — for both read and write shares.
+2. **D-03d enforcement:** in handleUpgrade, verify the server-fed recipientPublicKey against the node's pin
+ (via `client.getRecipientPubkeyPins` + `assertRecipientPinned`, 80-04) BEFORE the :306 re-wrap; fail
+ closed on mismatch/absent (D-03e).
+3. **owner-reconcile.service.ts:** verify its `runOwnerReconcile` path carries the 80-07 enforcement; add
+ any wiring needed so getPinsFn resolves real pins (no direct wrap reimplementation).
+
+Web is NOT unit-tested (logic lives in sdk-core; UI covered by Playwright web-e2e). Verify via typecheck +
+build + Puppeteer MCP (if available) / manual steps. Depends on 80-04 (issuance/read/assert helpers) and
+80-07 (enforced runOwnerReconcile) → wave 4.
+
+Purpose: close the web issuance write and the third fail-closed enforcement consumer.
+Output: ShareDialog issuance pin write + upgrade-path fail-closed compare; verified reconcile enforcement.
+
+
+
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md
+@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
+@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
+@apps/web/src/components/file-browser/ShareDialog.tsx
+@apps/web/src/services/owner-reconcile.service.ts
+
+
+
+
+
+ Task 1: D-03c issuance write in ShareDialog.handleShare
+ apps/web/src/components/file-browser/ShareDialog.tsx
+
+ - apps/web/src/components/file-browser/ShareDialog.tsx — `handleShare` (162-245): recipient pubkey decode (171-179), issuance wrap of itemReadKey (:184), resolveShareEncryptedWriteKey for write shares (:195), name wrap (:205), share-create result (:222-240); the item's ipnsName in scope
+ - packages/sdk/src/client.ts — `addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` (80-04)
+
+
+ After a share is successfully created in handleShare (both read-only and read-write branches), call
+ `getSdkClient().addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` to commit the pasted recipient
+ pubkey to the shared node's owner-sealed write-body pin list. Do NOT alter the issuance-time wrapKey
+ calls (:184/:205) — those remain exempt (the pin is being written here for the first time). Handle the
+ pin-write error path consistently with the existing share-create error handling (surface a user-facing
+ error; do not leave a share created without its pin silently — log + set error). Use the item's ipnsName
+ already in scope. No api:generate.
+
+
+ pnpm --filter @cipherbox/web typecheck 2>&1 | tail -5
+
+
+ - `grep -n "addRecipientPubkeyPin" apps/web/src/components/file-browser/ShareDialog.tsx` shows the issuance pin write in handleShare, for both read and write share branches
+ - The issuance-time wrapKey calls (:184/:205) are unchanged (no pin compare added there)
+ - `pnpm --filter @cipherbox/web typecheck` passes
+ - No files changed under packages/api-client/ (no api:generate)
+
+ Creating a share writes the recipient pubkey into the node's owner-sealed write-body pin list.
+
+
+
+ Task 2: D-03d fail-closed compare in the upgrade path + verify reconcile enforcement
+ apps/web/src/components/file-browser/ShareDialog.tsx, apps/web/src/services/owner-reconcile.service.ts
+
+ - apps/web/src/components/file-browser/ShareDialog.tsx — `handleUpgrade` (275-323): the server-fed recipientPublicKey decode (:297-300) and the resolveShareEncryptedWriteKey re-wrap (:303-306)
+ - apps/web/src/services/owner-reconcile.service.ts — the GrantRow decode (:57-84) and the `runOwnerReconcile` calls (:188, :244); CONFIRM enforcement is carried by 80-07's getPinsFn in buildGrantRemintCallbacks (does the service need to pass a pin-resolver/transport, or is it self-wired in sdk?)
+ - packages/sdk/src/client.ts — `getRecipientPubkeyPins` (80-04); packages/sdk-core/src/share/recipient-pins.ts — `assertRecipientPinned` (80-04)
+
+
+ In handleUpgrade, BEFORE the resolveShareEncryptedWriteKey re-wrap at :303-306, fetch the node's pins via
+ `getSdkClient().getRecipientPubkeyPins(itemIpnsName)` and call `assertRecipientPinned(recipientPublicKey, pins)`;
+ on throw, surface the existing upgrade-failure error path (fail closed — do NOT proceed to re-wrap).
+ For owner-reconcile.service.ts: verify the runOwnerReconcile path is enforced by 80-07 (getPinsFn wired
+ in buildGrantRemintCallbacks). If the service must supply a pin-resolver or a getRecipientPubkeyPins-capable
+ client to runOwnerReconcile for the seam to resolve real pins, add that wiring; otherwise leave the direct
+ re-wrap logic to sdk (do NOT reimplement the compare here). Do NOT weaken the server-fed decode; only gate
+ the re-wrap behind the pin check.
+
+
+ pnpm --filter @cipherbox/web typecheck 2>&1 | tail -5; pnpm --filter @cipherbox/web build 2>&1 | tail -5
+
+
+ - `grep -n "assertRecipientPinned\|getRecipientPubkeyPins" apps/web/src/components/file-browser/ShareDialog.tsx` shows the compare BEFORE the upgrade re-wrap at :306
+ - The upgrade path fails closed (does not call resolveShareEncryptedWriteKey) when the pin is absent or mismatched
+ - owner-reconcile.service.ts enforcement is confirmed to flow through runOwnerReconcile (80-07) — either self-wired (no change) or the service supplies the pin resolver; the compare is NOT reimplemented in the web layer
+ - `pnpm --filter @cipherbox/web typecheck` and `pnpm --filter @cipherbox/web build` pass
+
+ The web upgrade/reconcile re-wrap binds only to a pinned recipient, failing closed on relay substitution, without duplicating the compare in the web layer.
+
+
+
+
+
+Web is not unit-tested (logic in sdk-core; UI via Playwright web-e2e which only runs on main push). After
+typecheck/build pass, verify at runtime with Puppeteer MCP if available (per CLAUDE.md), else manually:
+1. Create a share to a recipient pubkey → confirm the share succeeds and (via a follow-up read) the node's
+ pin list includes that pubkey.
+2. Attempt a read→write upgrade with a tampered/mismatched recipientPublicKey → confirm the UI shows the
+ fail-closed upgrade error and no re-wrap occurs.
+The authoritative pre-ship gate is `tests/sdk-e2e` (live client→API IPNS round-trip); web-e2e runs on main push.
+
+
+
+- ShareDialog issuance pin write (addRecipientPubkeyPin) + upgrade-path fail-closed compare (assertRecipientPinned)
+- owner-reconcile.service.ts enforcement verified to flow through runOwnerReconcile (80-07)
+- Recovery-tool no-op tolerance is verified in 80-01 (apps/web/recovery-src never parses the write-body)
+- Pre-ship note: tests/sdk-e2e must pass before ship (key-lifecycle change)
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| server-fed store (share.recipientPublicKey) → web upgrade re-wrap | Untrusted relay value re-wrapped to at upgrade time |
+| owner (ShareDialog paste) → owner-sealed write-body | Issuance-time recipient pubkey committed as the pin |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-80-20 | Spoofing / Tampering | web upgrade re-wraps to server-fed recipientPublicKey | high | mitigate | assertRecipientPinned before the :306 re-wrap (D-03d consumer 3) |
+| T-80-21 | Spoofing | issuance binds the wrong recipient | high | mitigate | Issuance writes the pasted pubkey as the pin (D-03c); re-mint/upgrade later verify against it |
+| T-80-22 | Elevation of Privilege | empty/absent pin at upgrade treated as pass | high | mitigate | assertRecipientPinned hard-fails on empty/absent (D-03e) |
+
+No external packages added — no supply-chain (T-*-SC) threat for this plan.
+
+
+
+- `pnpm --filter @cipherbox/web typecheck` and `pnpm --filter @cipherbox/web build` green
+- Issuance wrap sites (:184/:205) unchanged; compare added only at the upgrade re-wrap
+- No api-client changes (no api:generate)
+- Manual/Puppeteer runtime check per manual_verification; tests/sdk-e2e is the pre-ship gate
+
+
+
+Share creation commits the recipient pubkey to the node's owner-sealed write-body, and the web upgrade and
+reconcile re-wraps bind the read/write key only to a pinned recipient (fail-closed on relay substitution or
+absent pin), reusing the sdk-core helpers with no API/DB change.
+
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md
new file mode 100644
index 0000000000..ec9d817b6f
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md
@@ -0,0 +1,177 @@
+---
+phase: 80-rotation-write-plane-and-re-mint-durability
+plan: 08
+subsystem: ui
+tags: [share, recipient-pins, ecies, react, sdk-facade, fail-closed]
+
+# Dependency graph
+requires:
+ - phase: 80-04
+ provides: "client.addRecipientPubkeyPin / client.getRecipientPubkeyPins + sdk-core assertRecipientPinned"
+ - phase: 80-07
+ provides: "runOwnerReconcile getPinsFn enforcement wired through buildGrantRemintCallbacks"
+provides:
+ - "ShareDialog issuance-time recipient-pin write (D-03c) on share creation"
+ - "ShareDialog fail-closed upgrade-path pin compare (D-03d consumer 3) before re-wrap"
+ - "web owner-reconcile.service.ts transport getRecipientPubkeyPins wiring — getPinsFn resolves real pins end-to-end"
+ - "@cipherbox/sdk facade re-export of assertRecipientPinned (D-07-compliant web access)"
+affects: [share, rotation, owner-reconcile, sdk-e2e]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Web reuses sdk-core pure pin helpers via the @cipherbox/sdk facade re-export (D-07 boundary), never importing @cipherbox/sdk-core directly"
+ - "Per-reconcile-pass transport factory closes over the root's shareRootIpnsName to resolve the ipnsName-keyed client pin read from the nodeId-keyed seam"
+
+key-files:
+ created: []
+ modified:
+ - apps/web/src/components/file-browser/ShareDialog.tsx
+ - apps/web/src/services/owner-reconcile.service.ts
+ - packages/sdk/src/index.ts
+
+key-decisions:
+ - "Placed addRecipientPubkeyPin immediately after sharesControllerCreateShare (unconditional, covering both read and write shares) so a pin-write failure throws into the existing catch and surfaces a user error rather than leaving a share silently un-pinned"
+ - "Re-exported assertRecipientPinned from the @cipherbox/sdk facade (mirrors the existing selectEncryptionMode re-export) to satisfy the D-07 no-restricted-imports boundary without reimplementing the compare in the web layer"
+ - "Built a per-pass makeWebOwnerReconcileTransport factory that closes over the root's shareRootIpnsName — the sdk-core seam threads rootNodeId, but client.getRecipientPubkeyPins is keyed by ipnsName, and each reconcile pass is scoped 1:1 to a single root"
+
+patterns-established:
+ - "Pattern 1: D-07-compliant reuse of a pure sdk-core helper in apps/web = re-export from the @cipherbox/sdk facade, then import from the facade"
+ - "Pattern 2: getPinsFn seam (nodeId-keyed) → web transport resolves via the reconcile pass's fixed shareRootIpnsName to the ipnsName-keyed client read"
+
+requirements-completed:
+ - "SC2 / D-03c (web issuance): ShareDialog writes the pasted recipient pubkey into the shared node's owner-sealed write-body pin list at grant creation"
+ - "SC2 / D-03d (consumer 3 of 3): the web upgrade path verifies the server-fed recipient pubkey against the pin before re-wrapping, and the web owner-reconcile path delegates to the enforced runOwnerReconcile"
+
+coverage:
+ - id: D1
+ description: "ShareDialog.handleShare commits the pasted recipient pubkey to the node's owner-sealed write-body pin list on share creation (both read and write shares) — D-03c issuance write"
+ requirement: "SC2 / D-03c (web issuance)"
+ verification:
+ - kind: manual_procedural
+ ref: "Create a share to a recipient pubkey, then read back the node's pins (getRecipientPubkeyPins) to confirm the pubkey is present — requires a running web + API + IPFS stack"
+ status: unknown
+ human_judgment: true
+ rationale: "apps/web has no unit tests (logic lives in sdk-core, UI covered by main-push web-e2e); runtime confirmation needs the full dev stack + a real recipient pubkey, out of scope for this scoped-verification executor"
+ - id: D2
+ description: "ShareDialog.handleUpgrade fails closed on relay substitution — assertRecipientPinned against getRecipientPubkeyPins BEFORE resolveShareEncryptedWriteKey re-wrap (D-03d consumer 3)"
+ requirement: "SC2 / D-03d (consumer 3 of 3)"
+ verification:
+ - kind: manual_procedural
+ ref: "Attempt a read→write upgrade with a tampered recipientPublicKey → confirm the UI shows the upgrade-failure error and no re-wrap occurs"
+ status: unknown
+ human_judgment: true
+ rationale: "Same as D1 — no web unit tests; the fail-closed path is exercised by tests/sdk-e2e (the pre-ship gate) and web-e2e on main push, neither run here per scoped-verification constraints"
+ - id: D3
+ description: "web owner-reconcile.service.ts transport supplies getRecipientPubkeyPins so runOwnerReconcile's 80-07 getPinsFn enforcement resolves real pins end-to-end (no more fail-closed-on-absent-seam)"
+ requirement: "SC2 / D-03d (consumer 3 of 3)"
+ verification:
+ - kind: unit
+ ref: "packages/sdk/src/share/owner-reconcile.ts buildGrantRemintCallbacks getPinsFn seam (unit-tested in sdk); web wrapper now satisfies the required transport method — typecheck confirms the OwnerReconcileTransport contract is met"
+ status: pass
+ human_judgment: false
+ - id: D4
+ description: "@cipherbox/sdk facade re-exports assertRecipientPinned so the web upgrade path reuses the sdk-core compare without violating the D-07 import boundary"
+ requirement: "SC2 / D-03d (consumer 3 of 3)"
+ verification:
+ - kind: unit
+ ref: "pnpm --filter @cipherbox/web exec tsc -b (pass) + eslint no-restricted-imports (pass) — the facade import resolves and satisfies D-07"
+ status: pass
+ human_judgment: false
+
+# Metrics
+duration: 20min
+completed: 2026-07-12
+status: complete
+---
+
+# Phase 80 Plan 08: Web recipient-pin issuance write + D-03d consumer 3 Summary
+
+**ShareDialog now writes the recipient pin at share creation (D-03c) and fail-closed-verifies the server-fed recipient against it before the upgrade re-wrap (D-03d), and the web owner-reconcile transport supplies getRecipientPubkeyPins so the 80-07 getPinsFn enforcement resolves real pins end-to-end.**
+
+## Performance
+
+- **Duration:** ~20 min
+- **Started:** 2026-07-12
+- **Completed:** 2026-07-12
+- **Tasks:** 2
+- **Files modified:** 3
+
+## Accomplishments
+
+- **D-03c issuance write:** `ShareDialog.handleShare` calls `getSdkClient().addRecipientPubkeyPin(item.ipnsName, recipientPublicKey)` immediately after `sharesControllerCreateShare`, committing the pasted recipient pubkey to the node's owner-sealed write-body pin list for both read and write shares. The issuance-time wraps (:184/:205) remain untouched — the pin is first written here, so they stay exempt.
+- **D-03d consumer 3 (upgrade path):** `ShareDialog.handleUpgrade` fetches the node's pins via `getRecipientPubkeyPins(item.ipnsName)` and calls the shared sdk-core `assertRecipientPinned` (reused through the facade — not reimplemented) BEFORE `resolveShareEncryptedWriteKey`. On mismatch/absent pin it throws into the existing upgrade-failure catch (fail-closed, no re-wrap). The server-fed `share.recipientPublicKey` decode is unchanged; only the re-wrap is gated.
+- **owner-reconcile transport wiring:** replaced the module-level `webOwnerReconcileTransport` with a `makeWebOwnerReconcileTransport(shareRootIpnsName)` factory that adds `getRecipientPubkeyPins`, so `runOwnerReconcile`'s 80-07 `getPinsFn` seam resolves real pins instead of failing closed on the previously-absent optional method.
+
+## Task Commits
+
+Both tasks committed together in a single commit (the two ShareDialog edits + the transport wiring + the facade re-export are one cohesive fail-closed enforcement change), with the SUMMARY in the same commit per plan constraint 4.
+
+1. **Task 1 + Task 2: D-03c issuance write + D-03d upgrade compare + reconcile transport wiring** — see commit below (feat)
+
+## Files Created/Modified
+
+- `apps/web/src/components/file-browser/ShareDialog.tsx` — issuance pin write in `handleShare`; fail-closed `getRecipientPubkeyPins` + `assertRecipientPinned` compare in `handleUpgrade` before the re-wrap; added `bytesToBase64` (crypto) and `assertRecipientPinned` (sdk facade) imports.
+- `apps/web/src/services/owner-reconcile.service.ts` — replaced the static transport with a per-pass `makeWebOwnerReconcileTransport(shareRootIpnsName)` factory that wires `getRecipientPubkeyPins`; updated both call sites (eager login sweep + opportunistic per-folder).
+- `packages/sdk/src/index.ts` — D-07-compliant facade re-export of `assertRecipientPinned` from `@cipherbox/sdk-core`.
+
+## Decisions Made
+
+- Unconditional `addRecipientPubkeyPin` after `createShare` (covers both read and write branches with one call) — placed inside the existing `try` so a failure surfaces the user-facing error instead of a silently un-pinned share.
+- Reused `assertRecipientPinned` via a new `@cipherbox/sdk` facade re-export rather than importing `@cipherbox/sdk-core` directly (blocked by the D-07 `no-restricted-imports` eslint rule) and rather than reimplementing the compare (prohibited by the plan).
+- Per-pass transport factory closing over `shareRootIpnsName` to bridge the seam's `nodeId` key to the client's `ipnsName` key — each reconcile pass is scoped to exactly one root, making the mapping 1:1.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Reused assertRecipientPinned via a @cipherbox/sdk facade re-export**
+- **Found during:** Task 2 (upgrade-path compare)
+- **Issue:** The plan directed importing `assertRecipientPinned` for the web compare, but `apps/web/src` is blocked from importing `@cipherbox/sdk-core` directly by the D-07 `no-restricted-imports` eslint rule, and the facade did not yet re-export it.
+- **Fix:** Added `export { assertRecipientPinned } from '@cipherbox/sdk-core';` to `packages/sdk/src/index.ts` (mirroring the existing `selectEncryptionMode` re-export) and imported it from `@cipherbox/sdk` in ShareDialog. No compare logic reimplemented.
+- **Files modified:** packages/sdk/src/index.ts, apps/web/src/components/file-browser/ShareDialog.tsx
+- **Verification:** `tsc -b` and `eslint` (incl. no-restricted-imports) pass on the touched web files; sdk facade builds clean.
+- **Committed in:** part of the plan commit.
+
+---
+
+**Total deviations:** 1 auto-fixed (1 blocking — necessary D-07-compliant wiring to reuse the helper).
+**Impact on plan:** The facade re-export is a third modified file beyond the two in `files_modified`, but it is the minimal, precedent-following way to satisfy both "reuse the sdk-core helper" and the D-07 boundary. No API/DTO/DB change; no scope creep.
+
+## Issues Encountered
+
+- **Type bridging:** `client.getRecipientPubkeyPins` returns `Uint8Array[]` while `assertRecipientPinned` expects base64 `string[]` (its stored-pin encoding). Resolved by `pins.map(bytesToBase64)` in ShareDialog — the same normalization sdk-core's engine applies to `getPinsFn` output. The reconcile transport returns raw bytes as the seam expects (sdk-core normalizes internally).
+- **No `typecheck` script on apps/web:** web typechecks via `tsc -b` (inside `build`). Ran `pnpm --filter @cipherbox/web exec tsc -b` directly for the type gate.
+
+## Verification Results
+
+- `pnpm --filter @cipherbox/web exec tsc -b` → exit 0 (clean)
+- `eslint` on `ShareDialog.tsx` + `owner-reconcile.service.ts` → 0 problems (after the facade fix)
+- `eslint` on `packages/sdk/src/index.ts` → 0 problems
+- Dependency dists rebuilt (`@cipherbox/core`, `@cipherbox/sdk-core`, `@cipherbox/sdk`, `@cipherbox/api-client`) so the web typecheck sees the new facade export
+- No `packages/api-client/` changes (no api:generate); no DB migration
+
+## Human Verification Required
+
+Per constraint 1 (apps/web has no unit tests; web-e2e is a main-push gate and was NOT run; sdk-e2e NOT run here), runtime confirmation is deferred:
+
+1. Create a share to a recipient pubkey → confirm the share succeeds and the node's pin list (via `getRecipientPubkeyPins`) includes that pubkey (D1).
+2. Attempt a read→write upgrade with a tampered/mismatched `recipientPublicKey` → confirm the UI shows the fail-closed upgrade error and no re-wrap occurs (D2).
+
+The authoritative pre-ship gate is `tests/sdk-e2e` (live client→API IPNS round-trip); web-e2e runs on main push.
+
+## D-03d Wiring Confirmation (all three points in place)
+
+1. **Issuance pin write** — `ShareDialog.handleShare` → `addRecipientPubkeyPin(item.ipnsName, recipientPublicKey)` after createShare. ✅
+2. **Upgrade-path assertRecipientPinned** — `ShareDialog.handleUpgrade` → `getRecipientPubkeyPins` + `assertRecipientPinned` before `resolveShareEncryptedWriteKey`, fail-closed. ✅
+3. **web owner-reconcile getRecipientPubkeyPins transport** — `makeWebOwnerReconcileTransport(shareRootIpnsName).getRecipientPubkeyPins` → `client.getRecipientPubkeyPins`, satisfying the 80-07 `getPinsFn` seam end-to-end. ✅
+
+## Next Phase Readiness
+
+- D-03d now has all three enforcement consumers wired (80-06 Rust, 80-07 TS re-mint, 80-08 web). The web issuance write (D-03c) and the third fail-closed consumer are complete.
+- Pre-ship: `tests/sdk-e2e` must pass (key-lifecycle change) before this branch ships.
+
+---
+*Phase: 80-rotation-write-plane-and-re-mint-durability*
+*Completed: 2026-07-12*
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
new file mode 100644
index 0000000000..e5bc55105b
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
@@ -0,0 +1,82 @@
+# Phase 80: Rotation Write-Plane and Re-Mint Durability - Context
+
+**Gathered:** 2026-07-12
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Close the remaining scope-exit rotation and re-mint correctness/durability gaps so rotated nodes stay owned-walkable and replay-recoverable, and re-mint stops trusting server-supplied recipient keys or doing O(nodes×shares) work.
+
+Bounded by the four ROADMAP source todos:
+
+- `2026-07-11-rotation-republish-drops-write-sealed-body` (HIGH) — SC1
+- `2026-07-11-remint-refetches-sent-shares-per-rotated-node` — SC2 (perf half)
+- `2026-07-11-remint-trusts-server-recipient-pubkey-binding` (MED) — SC2 (binding half)
+- `2026-07-11-ts-rotatednodes-defensive-copy-parity` (LOW) — SC3
+
+**Scope note:** The recipient-pubkey binding decision (D-03) deliberately expands this phase from a "closeout straggler" into a genuine sharing-crypto phase. This was chosen knowingly during discuss-phase (the alternative — documenting server-trusted recipient binding as an accepted risk — was declined). All other items are mechanical fixes, one with a verified prototype.
+
+**Depends on:** Phase 74 (made the FUSE re-mint path reachable), Phase 70.1.
+
+
+
+
+## Implementation Decisions
+
+### SC1 — Rotation republish drops `write_sealed` body
+- **D-01:** A scope-exit read-key rotation currently republishes every rotated node with `write_sealed: None` (the engine never populates it — read-key rotation is a read-plane op — and the FUSE adapter, a documented Phase-72 deferral, doesn't reconstruct it). This breaks `list_folder_owned` ("owned child … has no write_sealed body", observed 607× per run on macOS → the owner's background folder-metadata refresh permanently fails for any scope-exit-rotated shared subtree) AND is a **durability hole** (`replay.rs` can't recover the node's signing seed from the write body → after rotation + remount the owner may lose the ability to sign updates to the rotated subtree).
+- **D-01a (fix):** In `ApiClientTransport::publish` (`crates/fuse/src/write_ops/rotation_deps.rs`), when `node.write_sealed` is `None`, **reconstruct** `NodeWriteBody` from the mount's in-memory `InodeTable` — the node's own **stable write key** + `ipns_private_key` + child `WriteChildRef`s rebuilt from the child inodes (child write keys are **read-key-rotation-independent**) — and re-seal under the node's write key at the node's **NEW generation** via `seal_node` (which shares the `ROLE_BODY` AAD with `seal_published_node`'s write-body path). Round-trip: unseal under the write key at the new generation recovers the write body + child refs.
+- **D-01b (fallback):** Fail-open to `None` for a node **not locally materialized** (matches the existing signing-seed fail-closed lookup). Write-key *rotation* remains a separate Phase-72 concern — this only re-seals the **unchanged** write plane at the bumped generation.
+- **D-01c (tests):** Unit tests for the reconstruction round-trip + the `None` fallback (were authored in the prototype). Prototype verified locally: the "no write_sealed body" flood drops **607→0**.
+
+### SC2 (perf) — Re-mint refetches `/shares/sent` per node
+- **D-02:** `re_mint_grants_rooted_at` runs after **each** per-node commit during a rotation walk, and `query_grants_rooted_at` calls `collect_sent_shares()` (a full `GET /shares/sent`) every time → O(nodes × shares) network work. **Cache** the `collect_sent_shares()` result for the lifetime of a single rotation job and filter the cached list by `root_node_id` per node. Preserve the existing 0x-strip / hex-decode key parsing and per-share error handling. Mirror the optimization in the TS owner-reconcile `queryGrantsFn` for parity.
+- **D-02a (acceptance):** A scope-exit rotation over an N-node subtree performs **≤1** `/shares/sent` fetch (not N); re-mint results unchanged (retained recipients re-minted, revoked recipients cut by **absence** — revoked shares are hard-deleted server-side).
+
+### SC2 (binding) — Re-mint trusts the ZK relay for recipient-pubkey identity
+- **D-03:** **Pin the recipient pubkey end-to-end.** The recipient pubkey is authentic only at **issuance** (the owner pastes it out-of-band into ShareDialog; the server merely confirms a user exists via `lookupUser` and stores it). It becomes **server-trusted** whenever it round-trips back through the relay via `GET /shares/sent` — used by **three** consumers, all of which re-wrap the read key to the server-returned key without re-checking it:
+ 1. **Rust re-mint** — `rotation_deps.rs::query_grants_rooted_at` → `engine.rs::re_mint_grants_rooted_at` → `wrap_key(new_read_key, &grant.recipient_public_key)`.
+ 2. **TS re-mint** — `owner-reconcile.ts` `queryGrantsFn` → `sdk-core/rotation/engine.ts` wrap site.
+ 3. **Web upgrade/reconcile** — `owner-reconcile.service.ts` + the ShareDialog upgrade/downgrade path both read `share.recipientPublicKey` straight from the server-fed store and re-wrap to it.
+
+ A compromised relay that substitutes the pubkey in any of these responses causes the owner to ECIES-wrap the fresh post-rotation read key **to the attacker** — a confidentiality break against the exact adversary the zero-knowledge model names as untrusted. This trust is **inherited** (initial issuance already trusts the relay for recipient identity binding); pinning only re-mint would be incoherent, so the fix must cover **all three** consumers.
+
+- **D-03a (storage):** Store the issuance-time recipient pubkey(s) in the **shared root node's owner-sealed `NodeWriteBody`** — already sealed + AAD-bound under the owner's write key and IPNS-published, so it is **server-opaque** and **cross-device** by construction (a re-mint on a different owner device than the issuing one can still verify). A node shared to N recipients holds N pins (a list). The existing wrapped `encryptedReadKey` can't help: ECIES doesn't let the owner recover/verify the recipient pubkey from the blob without the recipient's private key, so the pubkey must be stored owner-side at issuance.
+
+- **D-03b (schema):** Adding the pin field to `NodeWriteBody` is a **metadata-schema change** — follow `METADATA_EVOLUTION_PROTOCOL` + update `METADATA_SCHEMAS`, and maintain **Rust/TS CBOR parity** for the new field (this repo's cross-language contract-test discipline applies; see `[[project-cross-language-verification-parity-gotchas]]`). The Phase-78 offline recovery tool must **tolerate** the new `NodeWriteBody` field (ignore-unknown) — verify it does not fail-closed on the added field.
+
+- **D-03c (issuance write):** At grant creation, write the pasted recipient pubkey into the shared root node's `NodeWriteBody` pin list (alongside the existing server-side create-share call).
+
+- **D-03d (enforcement):** On **all three** round-trip consumers, compare the `/shares/sent`-returned pubkey against the pin and **fail closed on mismatch**.
+
+- **D-03e (no legacy):** There are **no legacy shares** — the staging env is reset to a clean slate at milestone completion / deployment, so only the forward-looking case exists. Therefore **a pin absent at re-mint/upgrade is an invariant violation → hard fail-closed** (not a migration case). No TOFU, no backfill, no migration versioning.
+
+- **D-03f (server untouched):** The pin is purely client-side owner-sealed. The server still stores/returns `recipient_public_key` for its own `lookupUser`/response path — we just stop *trusting* it. **No API/DTO change → no `pnpm api:generate`.**
+
+- **D-03g (file-share carve-out — post-hoc, added during phase-80 CI hardening):** D-03c/d/e as written **implicitly assumed folder/root shares**. A shared **FILE** is a leaf node with **no `NodeWriteBody`**, so it structurally **cannot carry an owner-sealed pin** — issuance already skips pinning files (`addRecipientPubkeyPin` is folder-only). The gap: a scope-exit rotation of a folder that merely **contains** a separately-shared file rotates that file node and runs `re_mint_grants_rooted_at` for it → `query_grants_rooted_at` returns the file's grant → the (structurally empty) pin list → **D-03e "0 pinned" hard fail → the whole rotation aborts** (partial rotation → AES-GCM cascade). **Resolution:** the pin-enforcement layer (`re_mint_grants_rooted_at` in Rust `engine.rs` + `reMintGrantsRootedAt` in TS `engine.ts`) **exempts `nodeKind === 'file'` from the pin check** and re-mints the file grant directly. This is **not** a downgrade of the folder-share guard (folders/roots stay fully fail-closed); it accepts the **pre-existing** file-share recipient-substitution limitation (files were never pinned at issuance either — tracked in `todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md` §5). D-03e should therefore read: *a pin absent at re-mint/upgrade for a **folder/root** grant is a hard fail-closed; **file-rooted grants are exempt** (no pin is structurally possible).*
+
+### SC3 — TS `rotatedNodes` defensive-copy parity
+- **D-04:** The Rust engine `.clone()`s each node's key into an independent `Zeroizing<[u8;32]>` in `rotated_nodes`; the TS engine stores the **same `Uint8Array` reference** (`engine.ts:2064` root, `:2235` child), also aliased into `ParentTrackingState.parentNewReadKey`. Not a live bug today (`parentNewReadKey` is never zeroed), but a natural future D-09 tightening that zeroes it would silently zero the returned `rotatedNodes` entry → the FUSE consumer (`grant_scope.rs::refresh_rotated_inode_read_keys`) would refresh an inode read key to **all-zeros** → mis-decryption / data loss. Store a **defensive 32-byte copy**: `readKey: new Uint8Array(rootResult.childReadKey)` (root) and `new Uint8Array(result.childReadKey)` (child). Add a TS regression test asserting every `rotatedNodes` value's `readKey` is non-aliased with `parentNewReadKey`, non-zero, and equals the node's expected new key after `rotateReadFromNode`.
+
+
+
+
+## Success Criteria (from ROADMAP)
+
+1. Rotation republish no longer emits `write_sealed: None` for rotated nodes — owned-walks and replay signing-seed recovery survive a read-key rotation, locked by a regression test. **(D-01)**
+2. Scope-exit re-mint binds the new read key to a **verified** recipient public key (pinned/verified rather than blindly server-supplied) across all three round-trip consumers, and refetches `/shares/sent` once per rotation job (cached), not once per rotated node. **(D-02, D-03)**
+3. TS `rotatedNodes` stores a defensive 32-byte copy of `readKey` (no aliasing with `parentNewReadKey`), matching Rust parity. **(D-04)**
+
+
+
+
+## Relevant Memories / Prior Art
+
+- `[[project-fuse-scope-exit-rotation-stale-refresh-clobber]]` — the Part-D fix this bug was found orthogonally alongside (this is NOT the Part-D cause).
+- `[[project-write-plane-keyed-by-uuid-read-plane-by-ipnsname]]` — write-plane (UUID) vs read-plane (ipnsName) threading discipline.
+- `[[project-zeroization-callee-must-not-zero-reused-buffer]]` — zeroization ownership rules relevant to D-04.
+- `[[project-cross-language-verification-parity-gotchas]]` — CBOR Rust/TS parity gotchas relevant to D-03b.
+- `[[project-sdk-e2e-only-cross-package-publish-gate]]` — the gate to run before shipping IPNS/key-lifecycle changes.
+
+
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
new file mode 100644
index 0000000000..214b1d31f0
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md
@@ -0,0 +1,285 @@
+# Phase 80: Rotation Write-Plane and Re-Mint Durability - Pattern Map
+
+**Mapped:** 2026-07-12
+**Files analyzed:** 10
+**Analogs found:** 10 / 10
+
+## Correction to phase brief
+
+The write-body wire format is **plaintext canonical JSON, not CBOR**
+(`crates/core/src/node/encode.rs:110-124` `encode_write_body`, mirrored by
+`packages/core/src/node/encode.ts:140-155` `encodeWriteBody`). It is then
+AEAD-sealed as opaque bytes (`seal_node`/`seal_published_node`, ROLE_BODY
+0x01). The "CBOR integer-key dup-key/float" gotcha in
+`[[project-cross-language-verification-parity-gotchas]]` applies to a
+*different* wire structure (IPNS records), not `NodeWriteBody`. The D-03b
+cross-language parity test for the new pin field should mirror the
+**existing JSON KAT pattern** below (`node_write_body_vectors.rs` /
+`node-codec.json`), not a CBOR contract test. Plan accordingly — don't invent
+a CBOR encoder for this field.
+
+## File Classification
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|--------------------|------|-----------|-----------------|----------------|
+| `crates/core/src/node/types.rs` (`NodeWriteBody`) | model | transform | itself (add field) | exact |
+| `crates/core/src/node/encode.rs` / `decode.rs` | transform | transform | itself (add field, extend KAT) | exact |
+| `crates/core/tests/node_write_body_vectors.rs` | test | transform | itself (extend vector) | exact |
+| `packages/core/src/node/types.ts` / `encode.ts` / `decode.ts` | model/transform | transform | itself (add field) | exact |
+| `tests/vectors/node-codec.json` | fixture | transform | itself (add `pins`/pin-list vector) | exact |
+| `crates/fuse/src/write_ops/rotation_deps.rs::ApiClientTransport::publish` | service | request-response | `crates/fuse/src/write_ops/replay.rs` (write-body reconstruct + `seal_node`) | exact |
+| `crates/fuse/src/write_ops/rotation_deps.rs::query_grants_rooted_at` | service | CRUD | itself (add caching) | exact |
+| `crates/sdk/src/rotation/engine.rs::re_mint_grants_rooted_at` | service | event-driven | itself (add compare) | exact |
+| `packages/sdk-core/src/rotation/engine.ts::reMintGrantsRootedAt` | service | event-driven | itself (add compare) | exact |
+| `packages/sdk/src/share/owner-reconcile.ts::buildGrantRemintCallbacks` | service | CRUD | itself (add cache wrapper) | exact |
+| `apps/web/src/services/owner-reconcile.service.ts` | service | request-response | itself (decode pattern reused) | exact |
+| `apps/web/src/components/file-browser/ShareDialog.tsx` (issuance write) | component | request-response | itself (existing share-create call site) | exact |
+| `apps/web/recovery-src/walk.ts` | utility | file-I/O | itself — consumes `@cipherbox/core` `unsealNode`, inherits parity automatically | exact |
+| `crates/fuse/src/write_ops/grant_scope.rs::refresh_rotated_inode_read_keys` | service | event-driven | itself (D-04 consumer, no change needed — just verify) | exact |
+
+## Pattern Assignments
+
+### `crates/core/src/node/types.rs`, `encode.rs`, `decode.rs` (D-03b schema)
+
+**Analog:** itself — `NodeWriteBody` (`crates/core/src/node/types.rs:131-145`)
+
+```rust
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NodeWriteBody {
+ #[serde(with = "base64_key")]
+ pub ipns_private_key: Vec,
+ pub write_children: Vec,
+}
+```
+
+Add the pin list as a new field, e.g. `pub recipient_pins: Vec>` (or
+hex/base64-encoded `String`s to match `recipient_public_key` handling
+elsewhere — see `rotation_deps.rs:270-272` which strips `0x` and hex-decodes
+server-supplied keys; store pins in the **same encoding convention** so the
+compare in D-03d is a direct byte/hex comparison with no re-encoding step).
+
+Note `SealedChildRef` uses `#[serde(deny_unknown_fields)]`
+(`types.rs:100`) but `NodeWriteBody` does **not** — this is intentional and
+must be preserved: D-03 depends on `NodeWriteBody` tolerating unknown fields
+so `apps/web/recovery-src` (Phase-78, pinned to an older schema) doesn't
+fail-closed on the new field. Do not add `deny_unknown_fields` to
+`NodeWriteBody`.
+
+**Encode pattern** (`crates/core/src/node/encode.rs:110-124`):
+
+```rust
+/// FIXED field order (`ipnsPrivateKey` then `writeChildren`) so the output is
+/// deterministic and, once sealed under the writeKey, byte-identical to the
+/// frozen cross-language KAT...
+pub fn encode_write_body(wb: &NodeWriteBody) -> Result, NodeError> {
+ serde_json::to_vec(wb).map_err(|_| NodeError::SerializationFailed)
+}
+```
+
+Appending the new field to the struct changes the FIXED field order the KAT
+depends on — the existing `write_body_seal_matches_kat` test
+(`crates/core/tests/node_write_body_vectors.rs`) will need its oracle vector
+in `tests/vectors/node-codec.json` regenerated/extended (add
+`recipientPins` to `seal_vectors[].expected_published_node` or add a new
+vector), not silently left stale.
+
+**TS mirror** (`packages/core/src/node/encode.ts:140-155`,
+`decode.ts:317-345`, `types.ts:135-140`) — same field, same camelCase name,
+same base64/hex convention. `decodeWriteBody` currently manually validates
+`ipnsPrivateKey`/`writeChildren` shape (throwing `CryptoError` with code
+`DECRYPTION_FAILED` on malformed input) — extend with the same
+manual-validation style for the new field, defaulting to `[]` if absent
+(never throwing) so older-schema documents (Phase-78 recovery tool consumer,
+D-03e "no legacy" only applies to *shares*, not to bytes-on-disk written
+before this phase) don't fail-closed on read. Fail-closed only applies at
+the D-03d **compare** sites, not at decode.
+
+### Cross-language JSON KAT (D-03b test structure)
+
+**Analog:** `crates/core/tests/node_write_body_vectors.rs` (full file read,
+53-123) + `tests/vectors/node-codec.json` `seal_vectors[]`
+
+Pattern to replicate for the new field's parity test:
+- Load the same shared oracle `tests/vectors/node-codec.json` (`vectors_path()` helper, lines 17-21).
+- Deserialize a `SealVector` struct mirroring the JSON shape (`#[serde(rename = "...")]` for camelCase JSON keys).
+- Build a `NodeWriteBody` in Rust with the new field populated from the vector, call `encode_write_body`, seal with `encrypt_aes_gcm_aad` under the vector's `fixed_iv`/`write_key`, and assert byte-identical to `expected_published_node.write_sealed` (lines 97-121).
+- Guard `!vectors.seal_vectors.is_empty()` — no vacuous pass (line 76-79).
+- TS counterpart: `packages/core/src/__tests__/node-codec-vectors.test.ts` — same oracle file, asserts `encodeWriteBody`/`decodeWriteBody` byte-parity. Read that file's existing `write_sealed`/round-trip assertions before extending (not yet excerpted here — same vectors_path pattern as Rust, adjusted for `import.meta` / repo-root resolution).
+- Also add a **round-trip unit test** in the `#[cfg(test)] mod write_body_tests` block already in `encode.rs:126-` (existing example: `write_body_round_trip_populated`, lines 132-139) — extend it to cover the new field non-empty AND empty (mirrors this repo's convention of testing both populated and default-empty variants).
+
+### `crates/fuse/src/write_ops/rotation_deps.rs::ApiClientTransport::publish` (D-01a reconstruct)
+
+**Analog:** same file, `FuseRotationDeps::publish` doc comment
+(`rotation_deps.rs:371-378`) documents the `InodeTable` signing-key sourcing
+pattern already used by `publish` (lines 417-496) for the **read** plane;
+extend the same function for the **write** plane reconstruction.
+
+Fail-closed precedent to copy verbatim (lines 426-431):
+
+```rust
+let signing_seed = find_ipns_private_key(self.inodes, ipns_name).ok_or_else(|| {
+ RotationError::RotateFailed(format!(
+ "publish: no locally-cached IPNS signing key for {ipns_name} \
+ (node not materialized in the local inode table)"
+ ))
+})?;
+```
+
+D-01b (fallback to `None` for a non-materialized node) is the **inverse** —
+when the node/children aren't locally available, do NOT error; set
+`node.write_sealed = None` and proceed (matches current behavior, so this is
+an explicit opt-out path, not a new error). Use `InodeKind::Root { .. } |
+InodeKind::Folder { .. }`'s `children` map (see `grant_scope.rs:613-628` for
+the `InodeKind` match-arm idiom) to rebuild `WriteChildRef`s from child
+inodes' write keys.
+
+**Seal call** — use `cipherbox_core::node::seal_node` (`crates/core/src/node/seal.rs:48`, shares `ROLE_BODY = 0x01` AAD with `seal_published_node`'s write arm at `seal.rs:169-192`) at the node's **new generation**, mirroring `publish`'s existing `create_ipns_record`/`upload_content` sequencing (lines 434-463) — reseal happens before `upload_content`, same as the read body.
+
+**D-01c tests** — add unit tests beside the existing `ApiClientTransport` tests in this module (check bottom of `rotation_deps.rs` for existing `#[cfg(test)]`) for: (1) reconstruct round-trip (unseal under write key at new generation recovers the write body/children), (2) `None` fallback for a non-materialized node.
+
+### `query_grants_rooted_at` caching (D-02) + TS `queryGrantsFn` (D-02a)
+
+**Analog:** `rotation_deps.rs:264-286` (current per-call fetch) — add a
+`OnceCell`/`tokio::sync::OnceCell` or a plain `Option>`
+field on `ApiClientTransport` (constructed once per rotation job — check the
+job-scoped constructor, likely near `ApiClientTransport::new`/struct
+definition ~line 379) so `collect_sent_shares()` (line 498-506) is called at
+most once per job and `query_grants_rooted_at` filters the cached list by
+`root_node_id` (existing filter logic at line 268 is unchanged — just swap
+the fresh fetch for a cache read/populate).
+
+Preserve the existing per-share error handling exactly (lines 270-278: `0x`
+strip, hex-decode, per-share `RotateFailed` on bad key) — do not change
+error semantics, only add caching.
+
+**TS mirror:** `packages/sdk/src/share/owner-reconcile.ts::buildGrantRemintCallbacks` (lines 66-84) — `queryGrantsFn` currently calls `transport.listSentGrants()` fresh every invocation (line 71). Cache the `listSentGrants()` promise/result for the lifetime of the `runOwnerReconcile` call (function at lines 94-104) — e.g. lazily populate a closure-scoped variable in `buildGrantRemintCallbacks` shared across repeated `queryGrantsFn` calls within one reconcile pass. Same filter-by-`rootNodeId` logic stays (line 73).
+
+### Fail-closed pubkey pin compare (D-03d) — three consumers
+
+**Rust site 1 — `crates/sdk/src/rotation/engine.rs::re_mint_grants_rooted_at`** (lines 597-620):
+
+```rust
+async fn re_mint_grants_rooted_at(...) {
+ ...
+ let wrapped = cipherbox_crypto::wrap_key(new_read_key, &grant.recipient_public_key)
+ .map_err(|e| RotationError::RotateFailed(format!(
+ "re_mint_grants_rooted_at: wrap_key failed for share {}: {e}", ...
+ )))?;
+```
+
+Insert the pin compare immediately before this `wrap_key` call: fetch the
+root node's `NodeWriteBody.recipient_pins` (already unsealed as part of the
+rotation walk — thread it through the same way `deps` already carries other
+node state) and `RotateFailed` (same error type/format style) on mismatch or
+absent-pin (D-03e: absent = hard fail, not TOFU).
+
+**Rust site 2 — `rotation_deps.rs::query_grants_rooted_at`** doesn't wrap
+keys itself (that's the engine's job) — no compare needed there; the compare
+belongs in the engine per D-03d ("all three round-trip consumers" = the
+three **wrap** call sites, not the query call site).
+
+**TS site — `packages/sdk-core/src/rotation/engine.ts::reMintGrantsRootedAt`**
+(lines 563-590, wrap call at line 587):
+
+```typescript
+const wrappedBytes = await wrapKey(newReadKey, grant.recipientPublicKey);
+```
+
+Same insertion point — compare against the pin list before this call, throw
+(mirror this file's existing `Error` construction style, e.g. line 2764's
+`throw new Error('rotateWriteFromNode: wrapKey for co-writer failed', { cause: err })` pattern) on mismatch/absent.
+
+**Web site — `apps/web/src/components/file-browser/ShareDialog.tsx`**
+(wrap calls at lines 184, 205) and **`owner-reconcile.service.ts`** (decode
+at lines 57-69) — these are thin wrappers over the sdk-core/sdk functions
+above; the compare should live in sdk-core/sdk (D-03d's "three consumers"),
+not duplicated in the web layer, UNLESS the web ShareDialog upgrade/downgrade
+path calls `wrapKey` directly without routing through `reMintGrantsRootedAt`
+— confirm at the plan stage which of ShareDialog's two `wrapKey` call sites
+(184, 205) are issuance-time (trusted, no pin exists yet) vs
+reconcile/upgrade-time (must compare). Issuance-time calls are exempt (the
+pin doesn't exist until this call writes it — D-03c).
+
+### Issuance write (D-03c) — where to write the pin
+
+**Site:** `apps/web/src/components/file-browser/ShareDialog.tsx` around the
+existing share-create call (near line 184-222, where `recipientPublicKey`
+is ECIES-wrapped and the share row is POSTed). Add a client-side step here
+that updates the shared **root node's** `NodeWriteBody.recipient_pins`
+(append this recipient's raw pubkey), then re-seals and re-publishes that
+node's write body — reuse whatever "update this node's write body and
+republish" helper `packages/sdk-core/src/folder/registration.ts` already
+exposes for write-chain mutation (it already threads `WriteChildRef[]`
+updates through a merge+republish flow, e.g. `registration.ts:379-397`
+`mergedMap`/`byChildId` merge pattern) rather than hand-rolling a new
+publish path.
+
+### D-04 — TS `rotatedNodes` defensive copy
+
+**Analog:** the fix is already spec'd exactly by CONTEXT.md D-04, and the
+Rust side to mirror is `crates/sdk/src/rotation/engine.rs`'s
+`Zeroizing<[u8;32]>` clone-per-node pattern (search `rotated_nodes` insert
+sites in that file — same function family as `re_mint_grants_rooted_at`).
+
+**Current TS (to fix)** — `packages/sdk-core/src/rotation/engine.ts:2056-2059` (root):
+
+```typescript
+rotatedNodes.set(rootNodeIpnsName, {
+ ipnsName: rootNodeIpnsName,
+ readKey: rootResult.childReadKey,
+ ...
+});
+```
+
+and the child-branch equivalent at `:2227-2231` (`result.childReadKey`
+directly). Fix per D-04: `readKey: new Uint8Array(rootResult.childReadKey)`
+/ `new Uint8Array(result.childReadKey)`. The file already has the exact
+"defensive copy, not zeroed here" idiom to copy at line ~2068-2070
+(`parentOldReadKey: new Uint8Array(rootReadKey)` with the comment "a
+defensive copy of the caller-owned rootReadKey... owned by this tracking
+state so it can be safely zeroed on teardown below without touching the
+caller's buffer") — replicate that exact comment style for the `rotatedNodes`
+fix.
+
+**Regression test:** add near existing rotation-engine tests asserting
+`rotatedNodes` values are non-aliased with `parentNewReadKey`, non-zero, and
+equal to the node's expected post-rotation key (per D-04's spec).
+
+**Consumer — `crates/fuse/src/write_ops/grant_scope.rs::refresh_rotated_inode_read_keys`**
+(lines 613-628) is the Rust consumer of the *Rust* `rotated_nodes` map
+(already independently cloned via `Zeroizing`, no bug there) — this file
+needs no change for D-04 itself; it's cited in scope only as the FUSE
+consumer that a *future* TS-side zero-on-drop tightening would have broken.
+Confirm no Rust changes needed here beyond an optional comment/test noting
+the parity guarantee.
+
+## Shared Patterns
+
+### Fail-closed error style
+
+**Source:** `rotation_deps.rs:426-431`, `re_mint_grants_rooted_at` (engine.rs:610-615)
+
+All new fail-closed compares should use the same `RotateFailed(format!(": for : "))` message convention (Rust) and `throw new Error(': ', { cause })` (TS) already used throughout these two engines — do not introduce a new error type.
+
+### 0x-strip / hex-decode convention for recipient keys
+
+**Source:** `rotation_deps.rs:270-272`, `owner-reconcile.service.ts:57-59`, `ShareDialog.tsx:297-299`
+
+```typescript
+const bareHex = share.recipientPublicKey.startsWith('0x')
+ ? share.recipientPublicKey.slice(2)
+ : share.recipientPublicKey;
+```
+
+Reuse this exact idiom (already duplicated 3x in TS, once in Rust) if the pin list is stored/compared as hex — apply consistently so the D-03d compare is a straight equality check with no encoding mismatch.
+
+## No Analog Found
+
+None — all 10 files have a strong same-file or same-role exact match (this phase is entirely modifications to existing rotation/write-plane machinery, no genuinely new subsystem).
+
+## Metadata
+
+**Analog search scope:** `crates/core/src/node/`, `crates/core/tests/`, `crates/fuse/src/write_ops/`, `crates/sdk/src/rotation/`, `packages/core/src/node/`, `packages/sdk-core/src/rotation/`, `packages/sdk/src/share/`, `apps/web/src/services/`, `apps/web/src/components/file-browser/`, `apps/web/recovery-src/`
+**Files scanned:** ~20 (targeted reads/greps, no full-repo scan)
+**Pattern extraction date:** 2026-07-12
diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
new file mode 100644
index 0000000000..fff7a0f8fc
--- /dev/null
+++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md
@@ -0,0 +1,716 @@
+# Phase 80: Rotation Write-Plane and Re-Mint Durability - Research
+
+**Researched:** 2026-07-12
+**Domain:** Rust/TS cross-language sharing-crypto — NodeWriteBody re-sealing, ECIES re-mint, CBOR/JSON wire parity
+**Confidence:** HIGH (all code sites read directly; no framework-selection ambiguity — this is a closed-codebase surgical phase)
+
+## Summary
+
+This phase touches four narrow, already-located code sites (D-01 through D-04) inside an
+existing, well-tested `node/v3` codec and rotation-engine architecture. Three of the four
+(D-01, D-02, D-04) are mechanical fixes to functions that already exist and already have
+test scaffolding to extend. D-03 (the recipient-pubkey pin) is the one genuine net-new
+design surface: it requires (a) a new optional field on `NodeWriteBody` with matching
+Rust/TS wire-tolerance, (b) a **new SDK write path** to mutate-and-republish a shared
+node's own write-body pin list at share-issuance time (no such mutation path currently
+exists — `resolveShareEncryptedWriteKey` only *derives* the item's writeKey, it never
+writes back to the write-body), and (c) three independent fail-closed comparison sites
+threaded with access to that pin list.
+
+**Primary recommendation:** Sequence the four items D-01 → D-02 → D-04 → D-03, in that
+order. D-01/D-02/D-04 are additive, low-risk, and unblock the D-03 cross-language vector
+work (D-03's schema change is easiest to reason about once the write-body reconstruction
+path (D-01) is already flowing real write-body content through `rotation_deps.rs`). D-03
+is the only item requiring new cross-language KAT vectors and a net-new SDK method
+(`addRecipientPubkeyPin` or equivalent) — budget it as its own plan/wave.
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Write-body reconstruction on rotation republish (D-01) | API/Backend (FUSE transport adapter, in-process) | — | `ApiClientTransport::publish` is the FUSE-mount-local write-plane assembly point; no server involvement |
+| Sent-shares fetch caching (D-02) | API/Backend (SDK/rotation-engine callers) | — | Both Rust `FuseRotationDeps` and TS `owner-reconcile.ts` are the two callers issuing the redundant `GET /shares/sent` |
+| Recipient-pubkey pin storage (D-03a) | Database/Storage (IPFS-sealed `NodeWriteBody`) | — | Owner-sealed, IPNS-published — server-opaque by construction, not a DB column |
+| Recipient-pubkey pin enforcement (D-03d) | API/Backend (3 independent consumers: Rust FUSE, TS SDK, web service) | Browser/Client (ShareDialog upgrade/downgrade UI) | Each consumer independently re-wraps a key to a server-returned pubkey; each must independently verify against the pin before wrapping |
+| TS `rotatedNodes` defensive copy (D-04) | API/Backend (`packages/sdk-core` rotation engine, pure in-memory) | — | No I/O; a buffer-aliasing correctness fix inside the TS rotation walk |
+
+## Standard Stack
+
+No new dependencies. This phase is 100% internal crypto/codec/engine surgery inside the
+existing `node/v3` stack (`packages/core`, `crates/core`, `packages/sdk-core`,
+`crates/sdk`, `crates/fuse`). No package installs, so `## Package Legitimacy Audit` is
+not applicable — skipped.
+
+**Existing primitives this phase composes (verified in this session):**
+
+| Primitive | Location | Purpose |
+|-----------|----------|---------|
+| `seal_node` / `unseal_node` | `crates/core/src/node/seal.rs:48-74` | AES-256-GCM + AAD role `0x01` body seal, used by D-01's re-seal |
+| `seal_published_node` | `crates/core/src/node/seal.rs:169-209` | Seals BOTH read+write bodies into a `PublishedNode`; explicit `write_body: Option<&NodeWriteBody>` param (never a `Node` field — Landmine 2 in the module doc, deliberate to avoid a D-02/D-07 core/sdk split) |
+| `sealNode` / `unsealNode` (TS twin) | `packages/core/src/node/seal.ts:78-150` (approx; `unsealNode` at :121) | Same role, `writeKey` param is **optional** (line 124: `writeKey?: Uint8Array`) — write-body unseal is skipped entirely when omitted |
+| `encodeWriteBody`/`decodeWriteBody` | `packages/core/src/node/encode.ts:140-155`, `packages/core/src/node/decode.ts:317-364` | Manual (non-schema-validated) JSON encode/decode of `NodeWriteBody` |
+| `encode_write_body`/`decode_write_body` (Rust twin) | `crates/core/src/node/encode.rs:110-124`, `crates/core/src/node/decode.rs:113-118` | `serde_json::to_vec`/`from_slice` directly on the `NodeWriteBody` struct (no `deny_unknown_fields`) |
+| `wrap_key`/`unwrap_key` (ECIES) | `cipherbox_crypto` (Rust), `@cipherbox/crypto` (TS) | The re-mint/re-wrap primitive at every D-03 enforcement site — never hand-roll |
+
+## Package Legitimacy Audit
+
+Not applicable — no external packages are added by this phase.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```text
+ ┌─────────────────────────────────────────┐
+ │ Scope-exit read-key rotation walk │
+ │ (crates/sdk/rotation/engine.rs / │
+ │ packages/sdk-core/rotation/engine.ts) │
+ └───────────────┬─────────────────────────┘
+ │ per-node commit
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ rotate_one / rotateOne: mint readKey', reseal SealedChildRef│
+ └───────────────┬──────────────────────┬───────────────┘
+ │ │
+ (D-01) publish path │ (D-02/D-03) re_mint_grants_rooted_at
+ ▼ ▼
+ ┌────────────────────────────┐ ┌──────────────────────────────┐
+ │ ApiClientTransport::publish │ │ query_grants_rooted_at() │
+ │ (rotation_deps.rs) │ │ -> collect_sent_shares() │
+ │ │ │ GET /shares/sent │
+ │ if write_sealed==None: │ │ (D-02: cache once per job, │
+ │ reconstruct NodeWriteBody │ │ filter by root_node_id) │
+ │ from InodeTable, re-seal │ │ │
+ │ via seal_node at NEW gen │ │ for each non-revoked grant: │
+ │ (ROLE_BODY=0x01 AAD) │ │ (D-03) verify grant.recipient │
+ │ fail-open->None if node │ │ _public_key against the pin │
+ │ not locally materialized │ │ list read from THIS node's │
+ └──────────────┬───────────────┘ │ own NodeWriteBody, THEN │
+ │ │ wrap_key(new_read_key, pk) │
+ ▼ └──────────────────────────────┘
+ ┌────────────────────────────┐
+ │ PublishedNode.write_sealed │
+ │ (now populated) │
+ └──────────────┬───────────────┘
+ ▼
+ ┌────────────────────────────────────────┐
+ │ replay.rs::recover_signing_seed() │
+ │ unseals write_sealed -> ipns_private_key │
+ │ (D-01 durability consumer — the fix │
+ │ closes the "cannot recover signing seed"│
+ │ fail path) │
+ └────────────────────────────────────────┘
+
+ ┌────────────────────────────────────────────────────────────┐
+ │ D-04 (TS-only, in-memory, no I/O): │
+ │ rotateReadFromNode's rotatedNodes.set(ipnsName, { │
+ │ readKey: }) │
+ │ engine.ts:2057 (root) / :2228 (child) — aliasing bug │
+ │ Fix: wrap in `new Uint8Array(...)` at the .set() call only │
+ └────────────────────────────────────────────────────────────┘
+
+ ┌────────────────────────────────────────────────────────────┐
+ │ D-03 issuance write (NEW — no existing code path): │
+ │ ShareDialog.tsx::handleShare() (apps/web) pastes recipient │
+ │ pubkey -> MUST also write it into the shared root NODE's own │
+ │ write-body pin list (a new SDK mutate+republish call, │
+ │ sibling to the existing resolveShareEncryptedWriteKey which │
+ │ only DERIVES, never WRITES, to a write-body) │
+ └────────────────────────────────────────────────────────────┘
+```
+
+### Recommended Project Structure
+
+No new files/folders — every change is inside existing modules:
+
+```text
+crates/core/src/node/
+├── types.rs # D-03b: add recipient_pubkey_pins field to NodeWriteBody (Option>>, #[serde(default)])
+├── encode.rs # D-03b: encode_write_body — conditionally emit pin field
+├── decode.rs # D-03b: decode_write_body — tolerate absent field (already tolerant: no deny_unknown_fields)
+├── seal.rs # unchanged — seal_published_node already takes write_body: Option<&NodeWriteBody>
+
+crates/fuse/src/write_ops/
+├── rotation_deps.rs # D-01 fix (ApiClientTransport::publish), D-02 fix (cache collect_sent_shares), D-03 enforcement (query_grants_rooted_at)
+├── grant_scope.rs # D-04 downstream consumer (refresh_rotated_inode_read_keys) — READ ONLY, Rust side already correct (Zeroizing clone)
+
+crates/fuse/src/replay.rs # D-01 durability consumer (recover_signing_seed) — no code change, just a regression test target
+
+crates/sdk/src/rotation/engine.rs # D-02/D-03 fix (re_mint_grants_rooted_at, query_grants_rooted_at trait default)
+
+packages/core/src/node/
+├── types.ts # D-03b: add recipientPubkeyPins?: string[] (or Uint8Array[]) to NodeWriteBody
+├── encode.ts # D-03b: encodeWriteBody — conditionally emit pin field
+├── decode.ts # D-03b: decodeWriteBody — tolerate absent field (already manual/tolerant)
+
+packages/sdk-core/src/rotation/engine.ts # D-02 fix (queryGrantsFn caching contract), D-03 enforcement (reMintGrantsRootedAt), D-04 fix (rotatedNodes.set defensive copy at :2057/:2228)
+
+packages/sdk/src/share/owner-reconcile.ts # D-02 mirror (buildGrantRemintCallbacks caching), D-03 enforcement
+packages/sdk/src/client.ts # D-03c: NEW method to write the pin into a node's own write-body (sibling to resolveShareEncryptedWriteKey ~:3839)
+
+apps/web/src/services/owner-reconcile.service.ts # D-03 enforcement (3rd consumer)
+apps/web/src/components/file-browser/ShareDialog.tsx # D-03c issuance write (handleShare ~:162) + D-03d enforcement (upgrade path ~:286-327)
+
+tests/vectors/node-codec.json # D-03b: NEW seal_vector entry with a non-empty pin list (lockstep discipline)
+docs/METADATA_SCHEMAS.md # D-03b: document the new NodeWriteBody field, bump version-history table
+```
+
+### Pattern 1: Fail-open reconstruction with an explicit "not materialized" boundary (D-01)
+
+**What:** `ApiClientTransport::publish` must reconstruct `NodeWriteBody` only from data the
+FUSE mount already has plaintext access to in-memory (`InodeTable`), and return `None` for
+`write_sealed` rather than erroring when the node isn't locally materialized.
+
+**When to use:** Any time a republish needs write-plane data the current transport layer
+wasn't designed to carry, and a graceful degradation (not a hard failure) is the existing
+project convention for "node not in local cache" (see `find_ipns_private_key`,
+`crates/fuse/src/write_ops/rotation_deps.rs:555-576`, which already returns `Option` for
+exactly this reason).
+
+**Example — the existing sibling helper to mirror for reconstruction:**
+```rust
+// Source: crates/fuse/src/write_ops/rotation_deps.rs:552-576
+fn find_ipns_private_key(inodes: &InodeTable, ipns_name: &str) -> Option>> {
+ inodes.inodes.values().find_map(|inode| {
+ let (candidate_name, key) = match &inode.kind {
+ InodeKind::Root { ipns_name, ipns_private_key, .. } => (ipns_name, ipns_private_key),
+ InodeKind::Folder { ipns_name, ipns_private_key, .. } => (ipns_name, ipns_private_key),
+ InodeKind::File { ipns_name, ipns_private_key, .. } => (ipns_name, ipns_private_key),
+ };
+ (candidate_name == ipns_name && !key.is_empty()).then(|| Zeroizing::new(key.to_vec()))
+ })
+}
+```
+D-01's reconstruction helper should follow the identical `inodes.inodes.values().find_map`
+shape, additionally pulling the node's own **stable write key** and rebuilding each child's
+`WriteChildRef` from the child inodes' cached write keys (documented as
+"read-key-rotation-independent" in CONTEXT D-01a). The `InodeKind::{Root,Folder,File}`
+variants already carry `ipns_private_key`; confirm during planning whether they also cache
+a `write_key` field and child write-key material — if not, this is the actual scope
+boundary of D-01a (grep `InodeKind` definition in `crates/fuse/src/inode.rs` before
+planning the exact reconstruction fields).
+
+### Pattern 2: Single-fetch-per-job caching via an owned cache field, not a static/global (D-02)
+
+**What:** `collect_sent_shares()` (a full `GET /shares/sent`) is currently called once
+**per rotated node** from both `query_grants_rooted_at` (Rust, `rotation_deps.rs:264-286`)
+and `queryGrantsFn` (TS, `owner-reconcile.ts:66-84` calling `transport.listSentGrants()`
+fresh every invocation). Both call sites are simple pass-throughs with no caching layer.
+
+**When to use:** Any per-job (not per-request) invariant data set that a walk re-fetches on
+every per-node callback.
+
+**Fix shape:** Add a cache field to `FuseRotationDeps` (Rust) — e.g. an
+`Arc>>` or a simple `RefCell