# WEBSERVE W2 — attachments, `spt fetch`, message short-IDs, entry audience

Closes releases#246 and releases#147, and carries the releases#17 rider.
Base: `bfb5d58a`. Four requirements minted for the wave plus one minted in the
lane (`REQ-WEB-ENTRY-AUDIENCE` — the per-entry narrowing needed its own
requirement because ENFORCEMENT lives on the WEB gate, not inside a now-signal
category).

## What lands

**Attachments are pull-model** (ADR-0058). `spt send --attachment` snapshots the
bytes at send time into the node's own store, registers an entry, and the
message carries the URL and the size — never the bytes. The snapshot is the one
place the serving registry does not resolve at request time, and that is what
makes a message's attachment as immutable as the message: an edit after the send
does not change what the receiver pulls, and deleting the source does not turn a
delivered link into a dead one. Lifetime is a TTL, 30 days by default, and an
expired entry stops serving the moment it expires rather than when the reaper
next runs.

**`spt fetch`** is the one-command pull, and its exit codes separate the two
things a caller must not confuse: `3` is the owner's ACCESS DECISION (retrying
is wrong), `1` is a state that may change. The body streams to a temp file and
is renamed into place, so an interrupted fetch never leaves a truncated file
where a later reader would trust it.

**Message short-IDs** (ADR-0061) are minted at commit, not at render, so ONE
token names a message in the delivery envelope, in both io-event rows, and at
`/<node>/m/<id>`. `spt msg show` and the `m/` facet resolve through the same
function, so a link and a command cannot disagree.

**Per-entry `audience`** narrows an entry to one endpoint, enforced where the
fetch origin is proven, with the limit stated rather than implied: the handshake
proves a NODE and an audience names an ENDPOINT, so the check is whether the
proven node hosts that endpoint — it stops another machine, not another agent on
the audience's own machine.

**The FILE_ACCESS_HELPER** hands an agent the exact `spt fetch` line for a file
it was given, from either trigger: an attachment on a delivered message, or a
filepath the user quoted. The remote arm — a user on another node — registers on
the agent's behalf over the same stream family the proxy uses.

## The four refusals on `serve_for`, in the order they run

A register-on-my-behalf request asks a node to EXPOSE a file. It is refused
unless all four hold, cheapest first:

1. **The `WEB` access gate at node scope.** There is no entry yet to take a
   subject from, and asking a node to expose a file is at least as consequential
   as asking it to serve one.
2. **The requester may only ask on behalf of an endpoint IT HOSTS.** The
   handshake proves the requesting node; `audience` names an endpoint; if this
   node cannot place that endpoint on the proven node, the request is refused.
   Without this, a third node could ask us to expose a user's file to somebody
   else entirely.
3. **The user's authority** (gate finding F1). This node's own `MSG_OUT` row for
   the origin short-ID must exist, be a `user-msg`, be addressed to exactly this
   audience, and contain this exact path in the user's own words. **1 and 2
   alone are not enough**: `WEB` is default-on inside a subnet, so 1 admits every
   member and 2 only proves the audience lives where the asker says — together
   they would let any subnet node have this one expose any absolute path on it.
   Every fact in 3 is read from the owner's own record, so the requester can
   forge none of it.
4. **The path rules `serve add` already enforces**, unchanged.

1, 2 and 3 all answer with `deny_message()` — the same 403 body a surface deny
produces — so a narrowing cannot be told from a denial by probing.

## Two design decisions worth reading

**The helper's URLs are NOT written into the delivered envelope.** The obvious
carrier was the message's own `attachments` attribute, so the existing
attachment trigger would read them with no second arm. That is a forgery:
`attachments`, `msg-id` and `reply-to` are the sender-authored class this PR
pins in a proto cell, so a receiving node writing one would hand its agent a
link the sender never minted, wearing the sender's voice. The carrier is a
receiver-side record (`spt_store::helperline`) instead, and three cells hold the
line.

**The int arm for the remote helper was rewritten, not extended.** Its previous
positive asked with an origin the owner had never sent, and it PASSED — because
the F1 binding did not exist yet. That old green was the hole.

## Order of work

The remote arm (#17's cross-node registration) was built BEFORE the battery, on
the gater's order, rather than deferred: it was the one piece written after the
compiler was available, precisely because its only honest proof is the two-host
rig.

## Gates

- `cargo check --workspace --all-targets` — 0
- `cargo clippy --workspace --all-targets -- -D warnings` — 0, and zero warnings
- `cargo run -p xtask -- check` (docs drift) — 0, with `reference.md` regenerated
- `traceable-reqs check` — 0 findings; every W2 requirement carries doc, impl,
  unit and int
- Targeted battery: see the run recorded on the PR.

Clippy findings were fixed at the mechanism, never suppressed: two 8-argument
spool functions became a value, a wrapper that went dead when the extras axis
landed was collapsed into one gate, and the lints in my own cells were fixed.
