# WEBSERVE W2 — attachments, `spt fetch`, message short-IDs, entry audience

Fixes BigscreenVR/spt-bs-releases#246
Fixes BigscreenVR/spt-bs-releases#147
Fixes BigscreenVR/spt-bs-releases#17

Base: `ff4b405d` (rebased onto the landed #198). 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.
   The conjuncts themselves are `spt_store::msgid::user_authorized_serve_for`
   — pure, one unit cell each; `serve_for` looks the row up and calls 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.

The rewrite went further than the binding, because closing the hole exposed a
second one. The replacement arm drove `request_serve_for` **from the test
process**, and it did not run: it ate the brain IPC read deadline at the first
call. That client lives INSIDE the receiving daemon's WAN ingress in
production — the same place the fetch path's client lives — so the arm was
driving a client that does not exist. *A deadline I would have to widen to make
my own test shape work is the test being wrong about where the client lives.*

So the arm is now driven the only way production drives it: one real
`spt send --user-msg` from a Gateway-typed endpoint on B to an endpoint hosted
on A, quoting an absolute path that exists on B, and then **waited for on A's
helperline store**. That wait is the observable — it can only be satisfied by
the ingress caller firing after delivery and completing a daemon-to-daemon
round trip. The Gateway type is not decoration: WAN ingress re-stamps an
arriving `user-msg` down to a plain `msg` unless the origin endpoint is
advertised as a Gateway (REQ-MSG-6), and the arm asserts the send was not
degraded before it asserts anything that depends on it.

**The five F1 conjuncts had NO evidence outside the arm being reshaped.** That
is a measurement, not an impression: `msgid`'s existing cells cover
`local_msg_out_in`'s LOOKUP — found, not found, nested perch — and a grep across
every crate finds the conjuncts themselves named in exactly one place, the int
arm. They sat inline in the daemon's `serve_for`, reachable only through a QUIC
round trip. So reshaping that arm would have taken the gate finding's own fix
down to zero evidence, which is why the conjuncts moved rather than shrank:
they are now `spt_store::msgid::user_authorized_serve_for`, PURE, taking nothing
the requester sent except the `audience` and `path` the request is about, with
one unit cell per conjunct asserting both the refusal and the admit side of the
same inputs. `serve_for` looks the row up and calls it where conjunct 3 always
sat. The extraction is a fix, not a scope cut.

Four of the five cannot be produced end to end AT ALL — the receiving daemon
only ever asks for a path the user's own words carried, addressed to that
message's own target, under that message's own id — so the int arm keeps the
one that can: an agent-authored message quoting the same path, which is a
DIFFERENT gate (the receiver's own "only a `user-msg` triggers this") and has
no other evidence. Its zero is judged after the positive raises the same
counters through the same pipe, so it cannot be the zero of a dead emitter.

## Two defects the battery found, closed in-lane

Both are product defects this PR introduced and this PR fixes; neither is a test
accommodation.

**The quoted-path extractor trimmed once each in a fixed order.** A path written
the way people write them — `see "C:\x\notes.md".` — carries a closing quote
INSIDE the sentence dot, so one pass of quote-then-dot left the quote attached
and minted a URL naming a file that does not exist. It now trims to a FIXED
POINT. Measured before the fix: the token came back as `C:\x\notes.md"`.

**A torn helperline tail fused with the next record.** A crash mid-append leaves
a fragment with no newline; appending straight onto it produced one unparseable
line out of two records, so a single interruption cost TWO records rather than
one. `append_at` now closes a torn tail with a newline before writing. The cell
asserting that a torn line costs only itself was red until this existed — and
the module's own doc had already claimed the property the code lacked.

## Where the helper's evidence lives — read this before the gate table

Three witnesses. **None of them proves what another proves**, and saying so is
the point of this section: the coverage is narrower than a reader would assume
from the requirement's wording, and that is a fact about the rigs, not a
shortfall to be papered over.

1. **Gossip converges** — the preserved patch
   `.spt/preserved/pump-on-one-box-rig.patch`. Node A learned node B's
   Gateway-typed endpoint in **751 ms, identical across two invocations**, with
   the reachable set measured at one peer inside the pair. This is the estate's
   first datum that a pump-on `spt daemon run` rig works at all; every other rig
   pins `peer_pump: false` and calls it the hermetic kill switch. It is NOT on
   the lane, for the reason in the next paragraph.
2. **The daemon-to-daemon round trip** — `twohost_web`'s ONE new cell (`two_host_web_helper_role_a`) and its substantially rewritten role-B counterpart — a +1 to the binary's cell count against `ff4b405d` (4 -> 5), not a +2 — run on
   the local pair and climbed cross-box by the gate. B's user quotes a path, B
   registers it on its own user's authority, A's ingress fires after delivery,
   and A pulls the file byte-equal. The Gateway ROW is seeded in-process here,
   through `apply_feed_flips` under the real gate policy — this rig runs no pump
   either.
3. **Both together** — the gate's field leg on the operator-paired rig, two real
   hostnames and real gossip. Field evidence, not an `int` tag.

**The coverage sentence**, checked against the lanes rather than asserted:

- **Thin CI** carries the five predicate cells and **nothing cross-node**. This
  changed under this lane's feet and the sentence is written for the sha this PR
  carries, not the one it was drafted on: #198 added `webserve_cross_node_e2e`
  to BOTH `HEAVY` strings, so arm (iii) is **golden-only** now too. A PR that is
  green has exercised the five predicate cells and no cross-node arm at all.
- **Golden only, and only two steps of it**, carries the twohost positive:
  `twohost-web-b` (Linux, role B) and `twohost-web-a` (Windows, role A), each an
  explicit `cargo test -p spt-daemon --test twohost_web`. Those cells are
  env-gated on `SPT_TWO_HOST=1` plus a role, so everywhere else they are a
  silent no-op — including in the `HEAVY` group that names the binary.
- **Golden also carries arm (iii)**, via `HEAVY`'s serialized phase.
- **No single run carries gossip and the round trip together.** That pairing
  exists only in the field leg.

`webserve_attachment_e2e` is in both `HEAVY` strings by **hand**, and that hand
is load-bearing rather than belt-and-braces. #198's
`check_heavy_integration_classification` derives the qualifying set from a source
grep, and measured against its own predicate this binary scores **zero** on all
three literals: it autostarts its daemon through ordinary verbs (`send`,
`serve list`, `fetch`). The only daemon literal it carries is `["daemon",
"stop"]` — its reap. So the checker is blind to it, nothing classes it
automatically, and dropping the name silently returns a daemon-spawning binary
to the full-parallel pool.

So a PR that is green has not exercised the cross-node helper's positive at all.
That is the sentence I would want to read before trusting this milestone's
`int` tag, which is why it is here rather than in a commit message.

**Why not one rig.** Gossip-on and node-label routing are **mutually exclusive
on one box**. `webserve::known_subnet_node` prefers the registry-ADVERTISED
label over the roster's; `RegistryHost::new` sets that label from
`os_hostname()`; one box gives both daemons one hostname; and the prefix
dispatcher takes the LOCAL arm whenever the first segment equals the local
node's label. With the pump on, every peer-prefixed URL is answered locally and
arm 1 returns `404 NO_DOCS_LANDED`. There is no label override to escape through
(no `SPT_NODE_LABEL`, no `daemon.json` field; `COMPUTERNAME` is Windows-only and
`gethostname` has no hook), and label matching has no node-hex leg. Two hosts
carry two hostnames and the whole problem evaporates.

**A meter I built wrong, on the record.** The first version of the hermeticism
assertion read node A alone, 751 ms in, saw an empty set, and would have
reported the rig hermetic. A had learned from B's push and had not yet reached
its own cadence tick — so that zero was the zero of a meter that had not ticked,
not the zero of a quiet network. A guard against a false green produced a false
green. It now reads both daemons and refuses to judge the set until it is
non-empty, so the meter proves its own liveness before it is allowed to prove
anything else.

## Rig isolation, and one product seam found underneath it

The attachment rig was being answered by **this box's resident fleet daemon**.
It mints a URL, the URL named port 5474, and the 404 that came back was the live
daemon truthfully reporting an entry it had never heard of.

`SPT_TEST_EPHEMERAL_ADVISORY_PORTS=1` does not fix that, and the reason is a
product seam: `serveverb::node_and_port` mints the port from `DaemonConfig` /
`SPT_DOCS_PORT` and never asks what the listener actually **bound**, so under the
switch the daemon moves and the URL does not — **releases#282**, ruled a defect,
fixed outside this lane.

Pinning a fixed port then exposed the sharper half: the rig autostarts a daemon
and never stopped it, so the next run was answered by the **previous run's**
daemon — same port, different `SPT_HOME`, registry right and surface wrong.
`netstat` named the holder. The rig now picks its port per run and reaps its own
daemon on every exit path, panic included, because leaving a daemon behind is
what makes a fixed port unsafe.

## The `MSG_NOT_FOUND` defect, and why one string was the second defect

`spt msg show <id>` answered `MSG_NOT_FOUND` for an id read out of the funnel's
own `MSG_OUT` row. The cause is measured, not inferred, and it is bigger than
the arm that found it: **no spool row has ever carried a `short_id`.**

Three greps, each falsifiable:

- `spool_message_identified_at` and `IdentifiedInsert` — the only API that
  accepted an identity — have **zero callers** anywhere in the tree. The
  definitions are the only hits.
- The one live insert path, `insert_message`, passed `identity: None` as a
  literal.
- No `UPDATE` anywhere sets `short_id`; the five `UPDATE messages` sites all
  touch `delivered` / `taken_leg` / `body`.

So `resolve_in`'s spool leg was **unreachable in production by construction**.
This also refutes the shape I first reported it in: I had it as "the spool path
drops the id when the target is OFFLINE." It drops it always. Offline was where
I happened to be standing.

**The fix is one insert path, not two.** The identity is now derived at the one
`INSERT` from the envelope's own `msg-id` attribute, which is already in the
bytes being inserted — the id is minted before the envelope is composed
(ADR-0061). That is sound because `msg-id` and `reply-to` are sender-authored
and survive the receiver's ingress strip, which `spt-proto` already pins in
`the_w2_message_attrs_are_sender_authored_and_survive_the_strip`. A plain
non-envelope body parses to nothing and stores NULL, which is the honest answer
for a row that never had an id. The dead `spool_message_identified_at` /
`IdentifiedInsert` / `MessageIdentity` trio is **deleted** rather than left
beside the live path, because one of the two was always going to be the one
somebody read.

**The single string was a defect in its own right.** `MSG_NOT_FOUND` covered
three different misses with three different fixes — no index row, a spool row
with a NULL id, no `MSG_OUT` in the log the resolver read — and it sent me at
the wrong half twice. `resolve_detail_in` now answers a miss with `ResolveMiss`,
and every count is carried **beside the total it came from**: `0 of 7` names a
mechanism, a bare `0` cannot be told apart from "there is no spool here". The
public code is unchanged — `MSG_NOT_FOUND`, same exit code — and the three
`MSG_MISS_*` lines are stderr breadcrumbs of the `F1_ARM` class, said so in the
enum's own doc comment so nobody promotes them into a contract later. The `m/`
facet's 404 body is likewise unchanged; a fetcher is a stranger and the counts
describe this node's stores, so they go to the daemon's stderr.

## The two-host pair run, and the three rig defects under it

The `twohost_web` helper cell had never been compiled, let alone run. Five
attempts on the local pair, each red closed at a **named mechanism**:

1. **`RegistryUpdate` in the wrong module** — the type lives in
   `spt_net::net::replicate`, not `::registry`. One line.
2. **The cell reached for AMBIENT stores.** `RegistryGatePolicy::load()`,
   `perch::epoch_file()` and `presence::registry_snapshot_dir()` all resolve
   through the process-global `SPT_HOME`, which the runner unsets — so a role A
   cell was answered by **this box's real fleet home**, the policy carried none
   of the rig's subnets, and the merge returned `NotMember`. Role B can use
   those calls because it sets `SPT_HOME` at its own head; role A never had.
   The cell now claims its own `SPT_HOME` and calls the **product's**
   constructors unmodified — no rig copy of how a daemon builds its registry
   host. The proof is in the log rather than in this paragraph: the cell used to
   print `PAIR_MEET_UP` for `SPT_DEV`, `BIGNET` and `SPT_MANTLE` — the fleet's
   subnets — and now prints `PAIR_MEET_UP:twohost` and nothing else.
3. **A false green one line before the real failure.** B's send loop broke on
   any `Ok`, so a dialled-and-refused `NoPerch` printed "the user's message
   reached A (NoPerch)" and walked into a 240 s wait for a registration that
   could not come. The break condition is now the product's own
   `took_custody()`. That predicate choice was load-bearing in the other
   direction too: the honest success here is `Spooled`, not `Delivered` — the
   audience endpoint is recorded but not listening — so a hand-written
   `Delivered`-only list would have hung on the correct outcome.

Under those three sat the real one. With the message ADMITTED, A's ingress
**did** fire the helper and died at the last hop:

```
TWOHOST-WEB role B: the user's message was ADMITTED by A (Spooled)
HELPER_SERVE_FOR: path=…\quoted-by-the-user.md outcome=unanswered
  reason=no daemon to proxy through: The system cannot find the file specified. (os error 2)
```

That log pair is also the evidence for checklist **(b)**: an absent proxy
neither delayed nor failed the delivery. The cause is a rig gap, not a product
defect — `request_quoted_paths` dials `endpoint::seed_socket_name()`
(`spt-daemon-seed-{home_tag}.sock`, the node's canonical daemon socket) while
the cell answered on an invented rig name. Since the name derives from the home
and the cell now owns its home, the helper cell takes its broker name from the
product's own function; the other three A cells keep rig names, because nothing
dials into them.

**Isolation is measured, not asserted.** Setting `SPT_HOME` inside a cell is
only safe if nextest gives each `#[test]` its own process, so every A cell's
listener breadcrumb now prints its pid, and the run reports **four distinct
pids** for four cells.

**One measured limit, recorded for the field run.** A nextest `TIMEOUT` does not
always kill the cell. Attempt 4's role A sat **14 minutes** past its last
verdict at **0.61 CPU-seconds** — idle, not working — because a timed-out cell
left one live `twohost_web` child holding its broker threads and nextest blocked
on it; `a.exit` was never written while that child lived, so a finished run
looked hung and every verdict was already sitting in `a.raw`. Diagnose a hung
pair lane by **CPU time and an orphan census**, before reasoning about the
network. The rig-wide Drop-guard fix for thread-holding cells belongs to the
`IR-79` reaper lane, not here.

## 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.
