Peering: outbound send pipeline + transfer status #103

Closed
opened 2026-08-04 11:03:54 +00:00 by Cordy · 2 comments
Owner

The sender half: a user picks a file, a registered peer (from #101's registry) and a recipient; cairnd streams the file out per the #102 protocol.

Pipeline: resolve the file through the caller's own scoped/decorated store (so decryption happens with THEIR keys and read-authorization is enforced by the existing stack — sending is a read) → open connection to the peer URL → handshake → stream chunked → confirm content hash → record outcome.

API sketch:

  • POST /api/v1/peering/send {path, peer, recipient} → returns transfer id immediately
  • GET /api/v1/peering/transfers → caller's transfers (pending / streaming / done / failed+reason)

Async by design: a multi-GB file over WAN cannot ride one browser request. Background job with status polling; job state in a JSON store (no-DB rule), survives restart as failed (v1: no mid-flight resume, see #102).

Audit event peer-send (path, peer, recipient, outcome). Metrics: transfers started/succeeded/failed, bytes out.

Open questions:

  • Who may send? v1 recommendation: any authenticated user, admin toggle peering.userSendEnabled to restrict to admins — which default? ➤ Nikola (product).
  • Sending FROM a shared space: read-only members can read, so can they send a space file off-instance? That's exfiltration-adjacent — recommend write-members only, or even owner-only in v1. ➤ Manuel/Nikola.
  • Retry policy on transient failure: none (user re-triggers) vs N automatic retries with backoff. Recommend v1: none, keep the state machine trivial.
  • Folder sends: recommend files-only in v1 (mirrors the cross-scope rename limitation); folders = zip client-side or defer.
  • Bandwidth: any throttle (peering.maxConcurrentSends, per-stream rate cap) or trust the network? At minimum a concurrency cap, symmetric with #102's inbound cap.
  • Legal holds: a held file may not be deleted/renamed — may it be SENT? Reads are never blocked today, so sending is allowed by current semantics. Confirm that's intended for the public-sector persona (#59/#63). ➤ Nikola.
The sender half: a user picks a file, a registered peer (from #101's registry) and a recipient; cairnd streams the file out per the #102 protocol. **Pipeline:** resolve the file through the caller's own scoped/decorated store (so decryption happens with THEIR keys and read-authorization is enforced by the existing stack — sending is a read) → open connection to the peer URL → handshake → stream chunked → confirm content hash → record outcome. **API sketch:** - `POST /api/v1/peering/send` `{path, peer, recipient}` → returns transfer id immediately - `GET /api/v1/peering/transfers` → caller's transfers (pending / streaming / done / failed+reason) **Async by design:** a multi-GB file over WAN cannot ride one browser request. Background job with status polling; job state in a JSON store (no-DB rule), survives restart as `failed` (v1: no mid-flight resume, see #102). Audit event `peer-send` (path, peer, recipient, outcome). Metrics: transfers started/succeeded/failed, bytes out. **Open questions:** - Who may send? v1 recommendation: any authenticated user, admin toggle `peering.userSendEnabled` to restrict to admins — which default? ➤ Nikola (product). - Sending FROM a shared space: read-only members can read, so can they send a space file off-instance? That's exfiltration-adjacent — recommend write-members only, or even owner-only in v1. ➤ Manuel/Nikola. - Retry policy on transient failure: none (user re-triggers) vs N automatic retries with backoff. Recommend v1: none, keep the state machine trivial. - Folder sends: recommend files-only in v1 (mirrors the cross-scope rename limitation); folders = zip client-side or defer. - Bandwidth: any throttle (`peering.maxConcurrentSends`, per-stream rate cap) or trust the network? At minimum a concurrency cap, symmetric with #102's inbound cap. - Legal holds: a held file may not be deleted/renamed — may it be SENT? Reads are never blocked today, so sending is allowed by current semantics. Confirm that's intended for the public-sector persona (#59/#63). ➤ Nikola.
Author
Owner

Open questions resolved

Most of this issue's open questions were written before the ADR landed, and IMPLEMENTED-PEERING.md already answers them:

  • "Who may send?" — §4.2 decided: a per-peering allow-list of usernames and IdP groups, enforced by Registry.CanSend against the caller's live groups (#96). The proposed global peering.userSendEnabled toggle is superseded and should not be built.
  • "Can a read-only space member send space content off-instance?" — §4.2 decided: no. Write membership is required, explicitly because it is exfiltration-adjacent. Personal files need only the sender allow-list.

A file under a legal hold may not be sent off-instance. If any hold covers the file or a parent folder, the send is refused.

This is a deliberate departure from current semantics rather than an inherited one, and worth stating clearly: the hold decorator blocks delete, overwrite and rename, but not reads — and sending is a read. So without an explicit check, a legal hold would be trivially defeated by peering the file to another instance. That is the opposite of what a hold is for, particularly for the public-sector persona (#59, #63).

Scoped as "at least for now" — a later refinement could distinguish a hold that freezes mutation from one that also forbids egress, but v1 treats every hold as forbidding both.

Implementation seam: the sender takes a Held func(path string) bool rather than importing the hold store directly, so peering does not grow a dependency on internal/storage/hold and the wiring adapts whatever the real accessor is called.

Defaults taken (documented, not asked)

  • No automatic retries. A transient failure surfaces as failed with a reason; the user re-triggers. Keeps the state machine trivial, as the issue itself recommends.
  • Files only, no folder sends in v1. Mirrors the cross-scope rename limitation.
  • Concurrency cap, symmetric with the inbound cap from #102.

Also fixed while getting here

PrepareRequest.size is the plaintext size, but the wire carries age ciphertext, which is always larger. The data plane was capping the stream at exactly size and rejecting n != size — so every correctly-encrypted upload would have been truncated and then refused. It passed CI only because every test so far uploaded plaintext. Replaced with wireAllowance(size) (header + a tag per 64 KiB chunk) and the byte-count equality check dropped: integrity is the age AEAD and the plaintext content hash, both verified during delivery, not a length comparison over ciphertext.

## Open questions resolved Most of this issue's open questions were written **before** the ADR landed, and `IMPLEMENTED-PEERING.md` already answers them: - **"Who may send?"** — §4.2 decided: a per-peering allow-list of usernames and IdP groups, enforced by `Registry.CanSend` against the caller's live groups (#96). The proposed global `peering.userSendEnabled` toggle is superseded and should not be built. - **"Can a read-only space member send space content off-instance?"** — §4.2 decided: **no.** Write membership is required, explicitly because it is exfiltration-adjacent. Personal files need only the sender allow-list. ## Legal holds (decided by Nikola, 2026-08-06) **A file under a legal hold may not be sent off-instance.** If any hold covers the file or a parent folder, the send is refused. This is a deliberate departure from current semantics rather than an inherited one, and worth stating clearly: the hold decorator blocks delete, overwrite and rename, but **not reads** — and sending is a read. So without an explicit check, a legal hold would be trivially defeated by peering the file to another instance. That is the opposite of what a hold is for, particularly for the public-sector persona (#59, #63). Scoped as "at least for now" — a later refinement could distinguish a hold that freezes mutation from one that also forbids egress, but v1 treats every hold as forbidding both. **Implementation seam:** the sender takes a `Held func(path string) bool` rather than importing the hold store directly, so `peering` does not grow a dependency on `internal/storage/hold` and the wiring adapts whatever the real accessor is called. ## Defaults taken (documented, not asked) - **No automatic retries.** A transient failure surfaces as `failed` with a reason; the user re-triggers. Keeps the state machine trivial, as the issue itself recommends. - **Files only**, no folder sends in v1. Mirrors the cross-scope rename limitation. - **Concurrency cap**, symmetric with the inbound cap from #102. ## Also fixed while getting here `PrepareRequest.size` is the **plaintext** size, but the wire carries age ciphertext, which is always larger. The data plane was capping the stream at exactly `size` and rejecting `n != size` — so every correctly-encrypted upload would have been truncated and then refused. It passed CI only because every test so far uploaded plaintext. Replaced with `wireAllowance(size)` (header + a tag per 64 KiB chunk) and the byte-count equality check dropped: integrity is the age AEAD and the plaintext content hash, both verified during delivery, not a length comparison over ciphertext.
Cordy closed this issue 2026-08-06 02:14:22 +00:00
Author
Owner

Implemented

Merged in PR #126 (pipeline) and #127 (API + wiring), shipped v0.4.2. Files: internal/peering/{client,sender}.go, internal/api/peering_send.go.

Client — the protocol half

Client.Send drives Hello → Prove → Authenticate → PrepareTransfer → PUT → CompleteTransfer. Encryption streams through an io.Pipe, so a multi-gigabyte file is encrypted and sent as it is read and never buffered.

Verified by a genuine in-process loopback: real Service over bufconn, real BlobHandler over httptest, real FileDeliverer at the far end. That test is the first thing in the track that fails if any layer is wrong — handshake, capability, age-recipient direction, wire-size allowance, inbox path or hash check.

Sender — authorization and the state machine

Every refusal is synchronous, before Start returns, so the caller gets a real error rather than a job that dies a second later:

  • CanSend — the per-peering allow-list
  • Legal holdsHeld(p) || Affected(p), so a file inside a held folder is blocked too
  • Space write-membership — and Writable == nil means space content cannot be sent at all
  • Folders refused (files only in v1)

Transfers is atomic JSON, no database. On open, anything still pending or streaming becomes failed — v1 has no mid-flight resume, so a transfer that died with the process must not keep looking alive. ForUser scopes the list to its owner.

API

POST /api/v1/peering/send       → 202 {"id": …}
GET  /api/v1/peering/transfers  → the caller's own
GET  /api/v1/peering/peers      → peers this caller may send to

Not admin-gated: sending is a per-peering decision, so an admin without an allow-list entry cannot send and a non-admin with one can.

/peering/peers returns a projection, never the stored Peer — that struct carries the peering key, and returning it directly would hand a credential to every authenticated user. Tested.

Two things found while building this

The data plane would have cut every real upload. size is the plaintext size but the wire carries ciphertext. Detailed in the v0.4.2 changelog.

A replayed transfer id would have delivered a second copy. §6's idempotency only covers in-flight retries; after successful delivery the record is dropped, so a sender whose CompleteTransfer response was lost — exactly what this issue's async job does on restart — would duplicate the file. Added MarkDelivered/WasDelivered with a 24h TTL. At-least-once on the wire must not become at-least-twice on disk.

Defaults taken, per the issue's own recommendations

No automatic retries, files only, concurrency cap symmetric with inbound. The "who may send" and "read-only space member" questions were superseded by §4.2 and are recorded above.

## Implemented Merged in PR #126 (pipeline) and #127 (API + wiring), shipped v0.4.2. Files: `internal/peering/{client,sender}.go`, `internal/api/peering_send.go`. ### Client — the protocol half `Client.Send` drives `Hello → Prove → Authenticate → PrepareTransfer → PUT → CompleteTransfer`. Encryption streams through an `io.Pipe`, so a multi-gigabyte file is encrypted and sent as it is read and never buffered. Verified by a genuine in-process loopback: real `Service` over bufconn, real `BlobHandler` over httptest, real `FileDeliverer` at the far end. That test is the first thing in the track that fails if *any* layer is wrong — handshake, capability, age-recipient direction, wire-size allowance, inbox path or hash check. ### Sender — authorization and the state machine Every refusal is **synchronous, before `Start` returns**, so the caller gets a real error rather than a job that dies a second later: - `CanSend` — the per-peering allow-list - **Legal holds** — `Held(p) || Affected(p)`, so a file inside a held folder is blocked too - **Space write-membership** — and `Writable == nil` means space content cannot be sent at all - Folders refused (files only in v1) `Transfers` is atomic JSON, no database. **On open, anything still `pending` or `streaming` becomes `failed`** — v1 has no mid-flight resume, so a transfer that died with the process must not keep looking alive. `ForUser` scopes the list to its owner. ### API ``` POST /api/v1/peering/send → 202 {"id": …} GET /api/v1/peering/transfers → the caller's own GET /api/v1/peering/peers → peers this caller may send to ``` Not admin-gated: sending is a per-peering decision, so an admin without an allow-list entry cannot send and a non-admin with one can. `/peering/peers` returns a **projection**, never the stored `Peer` — that struct carries the peering key, and returning it directly would hand a credential to every authenticated user. Tested. ### Two things found while building this **The data plane would have cut every real upload.** `size` is the plaintext size but the wire carries ciphertext. Detailed in the v0.4.2 changelog. **A replayed transfer id would have delivered a second copy.** §6's idempotency only covers in-flight retries; after successful delivery the record is dropped, so a sender whose `CompleteTransfer` response was lost — exactly what this issue's async job does on restart — would duplicate the file. Added `MarkDelivered`/`WasDelivered` with a 24h TTL. At-least-once on the wire must not become at-least-twice on disk. ### Defaults taken, per the issue's own recommendations No automatic retries, files only, concurrency cap symmetric with inbound. The "who may send" and "read-only space member" questions were superseded by §4.2 and are recorded above.
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#103
No description provided.