# WEBSERVE W2 — attachments + message identity (todlando, releases#246 + #147, #17 rider)

Lane: `build/ws272-w2`, worktree `.worktrees/ws272-w2`, based on `build/ws272-w1` @ `8d980fdf`
(W1 is at doyle's gate; rebase onto main when W1 lands ff — expect a no-op rebase).

## Standing constraints (doyle, 2026-09-07 02:40Z)

- **No cargo until doyle's message "battery ended, pool released."** His W1 battery runs in my
  released `ws272-w1` pool. Then re-claim that pool from THIS worktree with `--foreign-pool`
  (it is warm at `8d980fdf`, my base) and build incrementally.
- **Targeted lane battery**, not a workspace sweep: treqs, xtask check, clippy workspace
  all-targets, `nextest --no-fail-fast` over the TOUCHED crates' units plus W2's own e2e/int
  bins, mdbook if docs moved. The filter expression goes in the driver's nextest leg and the
  full-sweep leg is dropped from the template. The full sweep runs once on the golden head.
- **Open the PR only when BOTH boxes are free of batteries** — a PR open is a CI battery on both
  runners, and W1's PR opened mid-battery and OOM-killed the kitsubito runner.
- Local `traceable-reqs` is BROKEN for this repo (see Infra below): run the `.bak`, not PATH.

## Infra hazard live right now

`traceable-reqs` on hfenduleam was replaced 2026-09-06 19:34 local (02:34Z) with **0.4.0**, which
reads NOTHING from `CONTEXT.md` — the one single-FILE entry in the manifest's `[scan]` roots.
Measured, one variable, same tree at `8d980fdf`, same command:

| binary | complete | incomplete | findings | exit |
|---|---|---|---|---|
| 0.2.0 (`traceable-reqs-0.2.0.exe.bak`) | 882 | 0 | 0 | 0 |
| 0.4.0 (installed, on PATH) | 799 | 83 | 396 | 1 |

CONTEXT.md evidence hits: 148 under 0.2.0, 0 under 0.4.0; total markdown evidence 622 to 474, a
difference of exactly 148. hertz confirmed it bidirectionally: all 83 missing reqs are tagged ONLY
in CONTEXT.md, and all 60 CONTEXT.md-tagged reqs still credited are double-tagged elsewhere. CI is
pinned to `WANT=0.2.0` (ci.yml:246, golden.yml:1320), so the GATE is unaffected — this bites local
runs only. hertz owns the fix (IR-37, branch `ci/traceable-reqs-v0.4`), and its scope was proven
incomplete: `[placement].module_banner = "accept"` cannot clear the 83.

**CORRECTED 2026-09-07 03:46Z — THIS HAZARD IS CLOSED, do not carry it forward.** doyle pinned
the box exe back to 0.2.0 at 02:50Z. Measured in this shell at 03:46Z: `command -v traceable-reqs`
= `/c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs`, `--version` = 0.2.0, and a
`check` over this tree gives the SAME single finding the `.bak` gives. Both 0.2.0 and 0.4.0 remain
beside it as `.bak` copies. The measurement table above is the record of what 0.4.0 did, not a live
instruction — run PATH.

## Requirements minted (5, registry parses; exit 1 = coverage, never exit 2)

- `REQ-WEB-ATTACHMENT-PULL` — snapshot at send time, additive `attachments` envelope key, ttl
  default 30 d, pulse reaper deleting file + entry together and counting.
- `REQ-WEB-FETCH-VERB` — `spt fetch <url> [dest]`, exit 0 / 3 refused / 1 failed, temp-then-rename.
- `REQ-MSG-SHORT-ID` — 8-char base32 minted at commit, envelope + MSG_IN/MSG_OUT + `/m/<id>`,
  collision lengthens one id, `spt msg show`, `spt send --reply-to`.
- `REQ-WEB-ENTRY-AUDIENCE` — **minted in this lane, not in the JIT brief.** Per-entry narrowing
  enforced on the WEB gate (owner side), because enforcement is a gate change, not a signal.
- `REQ-NOW-SIGNAL-FILE-ACCESS-HELPER` — the category, both triggers, the guards, the delta.

W0 already shipped `ServedKind::Attachment`, `ServedEntry.ttl_ms` and `ServedEntry.audience` as
forward-compat schema (`webserve::serve_entry` 404s the Attachment kind on purpose). W2 ENFORCES
and SERVES what W0 declared.

## Open ruling requested from doyle (asked 02:52Z, not blocking)

`spt send --reply-to` was HARD-REMOVED once: `REQ-SEND-REPLYTO-REMOVE` (v0.16.0 W3, ADR-0020),
unit cell `send_reply_to_flag_is_removed_and_target_required`. Re-adding the spelling reds that
cell. They are not the same flag — the removed one was a target-fallback plus a REPLIED label with
NO wire effect; ADR-0061's carries a parent short-ID in the envelope and leaves target a required
positional. **Assumption I am building on:** amend `REQ-SEND-REPLYTO-REMOVE` to the semantics it
actually killed and REPIN its cell in the same change (the `REQ-ACL-ACCESS-REFRESH-ER-ONLY`
precedent). Alternatives doyle may pick: retire the old requirement, or spell the new flag
`--in-reply-to` / `--parent`.

## DONE so far (source only, nothing compiled)

- `crates/spt-store/src/perch.rs` — `serve_snapshots_dir_in` / `serve_snapshots_dir`.
- `crates/spt-store/src/serving.rs` — `add_attachment`, `scope_entry` (ttl + audience + origin on a
  LIVE reference, for the #17 helper), `expired`, `reap_expired`, `write_snapshot_in`,
  `audience_admits`; 5 unit cells (snapshot immutability vs edit AND delete, two sends of one name
  getting `report.md` + `report~1.md`, directory and missing path refused where the snapshot is
  taken, expiry boundary + backwards clock + no double reap, audience absent/named/other/loopback).
- `crates/spt-store/src/msgid.rs` — NEW. base32 alphabet without 0/1/8, `message_digest` with
  length-prefixed fields, `short_id_of`, `is_short_id`, node-level index at
  `$SPT_HOME/messages/index.db` (`open_index_at`, `mint`, `lookup`, `mint_in`, `lookup_in`).
  The PRIMARY KEY is the collision detector; a taken id lengthens; a byte-identical repeat advances
  a salt so the loop terminates on repeats as well as collisions. 6 unit cells. Registered in
  `lib.rs`.
- `crates/spt-store/src/spool.rs` — additive NULLABLE `short_id` + `reply_to` columns (idempotent
  ALTER, the house pattern), `MessageIdentity`, `IdentifiedMessage`, `insert_message_identified`
  (ONE clock reading shared by row and id), `spool_message_identified_at`, `read_identified_at`.

## STATE 2026-09-07 ~06:30Z — RULING A MEASURED, C EXECUTING, BOX YIELDED

READ THIS FIRST: the box is YIELDED to hertz (his ci/rig-advisory-ports PR). My PR opens
behind his. Nothing of mine runs. Everything below needs the box only when it says cargo.

### What is DONE and GREEN (exit files under .spt/ws272-w2-gate/, each Summary EXACTLY 1)
  f1-check.exit 0 · f1-clippy.exit 0 (-D warnings, zero warnings) · f1-xtask2.exit 0 (after
  `gen`; reference.md 4 lines, from the uncommitted `fetch` help reflow) · f1-treqs.exit 0 ·
  f1-units.exit 0 (54/54, the five conjunct cells by name) · f1-storecells.exit 0 (8/8) ·
  f1-crossnode6.exit 0 (W1 arms 1-8 + arm (iii)).
  f1-falsifier-predicate.exit 100 — PRE-REGISTERED: predicate forced to Admit reds EXACTLY the
  five conjunct cells (16 passed / 5 failed), source restored and cmp-verified byte-identical.

### The two rulings that shaped this, and the premises they turned on
- 05:41Z RULING A (predicate extraction). My measurement refuted his premise: the F1 conjuncts
  had ZERO unit cells; the only thing exercising them was the arm being reshaped.
- 06:04Z RULING A-TIMEBOXED for the int arm, C as fallback. ANSWERED EARLY at ~15 min:
  pump-on WORKS (A learned B's gateway by gossip in **751 ms, identical across two
  invocations**; reachable set **1 peer, in the pair, in 10 ms**) but it BREAKS routing on one
  box — `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 when the first segment equals the
  local label. Arm 1 went 404 NO_DOCS_LANDED. **Gossip-on and node-label routing are mutually
  exclusive on ONE box; arm (vii) needs both.** No label override exists (no SPT_NODE_LABEL, no
  daemon.json field; COMPUTERNAME is Windows-only, gethostname has no hook), and
  `known_subnet_node` matches labels only, so a node-hex prefix is not an escape.
  -> C CONFIRMED as FORCED. Pump-on work preserved: `.spt/preserved/pump-on-one-box-rig.patch`,
  headered with both numbers AND the first-meter false-zero trap (my first meter read A alone at
  751 ms, saw {}, and would have called the rig hermetic).

### C, as doyle bound it (06:13Z + 06:20Z)
1. one-box rig: pump OFF, keeps arm (iii) ONLY. DONE, green.
2. (vi)/(vii) -> `crates/spt-daemon/tests/twohost_web.rs` as cells of THIS lane. NOT blind:
   a local pair runner exists at
   `.spt/preserved/gate-w1-9489ef60/twohost-web-local.sh <gate-worktree> <label> [none|A|B]`
   (both roles over loopback, ports A=7460 B=7470, WAIT env, mutation arms, identity scrub).
   BUILD, then EXECUTE there, read the two exit files, THEN open the PR. doyle climbs it
   cross-box before LAND (twohost-web-xbox.sh, kitsubito role B).
   Cost measured before writing: role A cells have NO dispatch loop (cannot receive a WAN
   message today); role B has NO send path (must hand-roll a WanMessage over Brain, since
   `spt::wansend` is in the `spt` crate); A's RegistryHost gets B's gateway row seeded
   in-process (this rig runs no pump either). ~300 lines. Each role's growth in its OWN commit
   with a header naming what the role could not do before.
3. Falsifier 1 (no-op `request_serve_for`) rides as the GOLDEN-ONLY witness, and must be run
   once on the local pair so its RED is observed by a person. Add it as mutation arm `C` to
   twohost-web-local.sh.
4. THREE witnesses named SEPARATELY in the PR body, none reading as another:
   (1) the preserved pump patch proves gossip converges; (2) twohost proves the daemon-to-daemon
   round trip with the gateway row seeded in-process; (3) doyle's FIELD leg on the operator
   paired rig proves gossip AND round trip together, as field evidence, not an int tag.
   Coverage sentence is load-bearing: thin CI carries the five predicate cells + (iii); golden
   carries (vi)/(vii).

### The attachment cell — STILL RED, handed over mid-diagnosis
`f1-attach8.exit = 100`. Six reds CLOSED, each a real mechanism, in order: (1) bare directory
for a perch — `perch_exists` reads `info.json`; (2) the minted URL pointed at **5474, the
resident FLEET daemon**, which 404'd about an entry it had never heard of; (3)
`SPT_TEST_EPHEMERAL_ADVISORY_PORTS=1` does NOT fix that — it moves the listener but not the
URL, because `serveverb::node_and_port` reads the port from config and never asks what was
bound (**releases#282**, doyle ruled DEFECT not contract; cite it in the PR body); (4) a FIXED
private port then let the PREVIOUS RUN's daemon answer — this rig autostarts a daemon and never
stopped it, so registry-right + surface-wrong was a stale listener on the same port, different
`SPT_HOME` (netstat named pid 54684; reaped by pid, scoped; the fleet's 14444 untouched);
(5) the SENDER needs a perch record too — that is where the io funnel writes MSG_OUT;
(6) the rig read `io.log` when the file is `io-events.log` (`iolog::IO_LOG_FILE`, whose own doc
calls itself the single path source) — now read through `iolog::read_after_at`.
FIXED IN THE RIG: a PER-RUN port (bind :0, read the number, feed the daemon and every CLI call)
plus a `DaemonReaper` armed before the first CLI call. The fetch arms now PASS.
**WHERE IT DIES NOW:** `spt msg show <id>` -> `MSG_NOT_FOUND` for an id read straight out of the
funnel's own MSG_OUT row, with NO `MSG_ID_WARN` in the raw. `msgid::resolve_in` is
index -> spool -> io-log and it returns None at the FIRST step, so the INDEX has no row for an id
the envelope carried. Either a real mint/index defect in W2's own send path or one more rig gap
— NOT yet called. NEXT CYCLE: put the index's existence and row count into the failure panel.

### doyle's gate reads on that rig, build for them
1. the ephemeral flag must stay OFF there (IR-78: `resolve_daemon_docs_port` returns 0 before the
   env is read, so it would eat the pinned port). Raw must show `DOCS_SERVER_UP` port == the port
   in every minted URL, one line each — he greps both. ASSERTION NOT YET ADDED.
2. the reaper's stop must breadcrumb in the raw (it does) AND a post-leg census by `SPT_HOME`/exe
   path must show no `spt.exe` from the rig's home. CENSUS NOT YET ADDED.
3. keep the const's three-measurement doc comment; cite #282 on the URL sentence.

### Board
releases#282 = the minted-URL port defect (backlog, BUGFIX). #281 = doyle's prune/registry
findings, which my RegistryHost write-only read joins as a second face. Neither is my lane.

## SUPERSEDED — the 06:0Xz state, kept for the record

## STATE 2026-09-07 ~06:0Xz — THE F1 INT RESHAPE IS BUILT (doyle's ruling A)

Doyle RULED A at 05:41Z after my measurement refuted his 05:30Z premise: there were NO
unit cells for the four/five F1 conjuncts. `msgid`'s cells cover `local_msg_out_in`'s
LOOKUP only; a grep across every crate found the conjuncts named in exactly ONE place —
the int arm being reshaped. So (b) was never available and reshaping the arm would have
taken his own gate finding's fix to zero evidence. He recorded it that way.

WHAT SHAPE A BINDS (his five lines, all built):
1. The predicate is PURE and takes the looked-up ROW plus only the `audience` and `path`
   the request is about — nothing else the requester sent. User-vs-agent is decided from
   the row's envelope.
2. One unit cell per conjunct, refusal AND admit side against a shared admitted fixture,
   each named after the conjunct it pins.
3. `serve_for` looks the row up and CALLS the predicate where conjunct 3 always sat
   (between the audience check and the path rules), refusing with `deny_message()`.
4. Int arm = (iii) end to end + (vi) + (vii). Three F1_ARM breadcrumbs. (iii) asserts
   registry AND helperline unchanged.
5. Falsifiers pre-registered, separate invocations, both on a green baseline:
   no-op `request_serve_for` -> (vi)/(vii) RED; predicate forced to Admit -> the refusal
   unit cells RED, int arm unchanged.

BUILT THIS SESSION:
- `spt-store/msgid.rs` — `user_authorized_serve_for(origin, row, audience, path)`, pure,
  five conjuncts, plus FIVE unit cells: `an_absent_origin_refuses_where_a_present_one_admits`,
  `an_unfound_row_refuses_where_a_found_one_admits`,
  `an_agent_attributed_row_refuses_where_a_user_row_admits`,
  `a_target_other_than_the_audience_refuses`, `a_path_the_words_never_named_refuses`.
- `spt-daemon/webproxy.rs` — `serve_for` now looks the row up and calls the predicate.
- `spt/tests/webserve_cross_node_e2e.rs` — arm 6c REPLACED. New rig helpers:
  `typed_perch`, `seed_a_registry`, `send_as`. A's registry is seeded BEFORE its daemon
  boots, because the WAN user-msg gate reads the origin endpoint's advertised type out of
  the `RegistryHost`'s IN-MEMORY copy (loaded once at start) — a mid-test write is invisible.

THE DRIVE, and why it is this and not the old one: 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; then WAIT on A's helperline store. Gateway-typed is load-bearing — WAN ingress
re-stamps an arriving `user-msg` to plain `msg` unless the origin endpoint is advertised as
a Gateway (REQ-MSG-6), and the arm asserts no `USER_MSG_RESTAMP` before anything that
depends on it. Nothing calls `request_serve_for` from the test process.

WHY FOUR CONJUNCTS ARE NOT DRIVEN END TO END: 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. Driving (i),(ii),(iv),(v) from the rig would mean forging a request the
product cannot make. (iii) IS drivable and stays — it is the RECEIVER's own gate and has
no other evidence. Its zero is judged AFTER the positive raises the same counters through
the same pipe, and both messages quote the SAME path, so a fired (iii) would leave a
SECOND helper line: the assertion is the COUNT, not the presence.

GATES SO FAR THIS SESSION, each read from cargo's own exit file:
  `.spt/ws272-w2-gate/f1-check.exit` ......... 0
  `.spt/ws272-w2-gate/f1-units.exit` ......... 0, Summary EXACTLY 1, 54 run / 54 passed,
      all five conjunct cells PASS by name
  `.spt/ws272-w2-gate/f1-crossnode.exit` ..... the solo leg (in flight at time of writing)
Disk before the leg: 105 GB free (floor 80).

STILL TO DO: solo leg green -> clippy -> xtask check -> traceable-reqs -> the two
falsifiers -> commit -> PR (PR-BODY-W2.md is written and already carries the measurement,
the two in-lane defects, and doyle's verbatim deadline line) -> pool release -> his gate.

BOX (doyle 05:51Z): hfenduleam is MINE. hertz holds kitsubito for a short leg; if a
kitsubito leg is ready before his exit files exist, WAIT — one battery per box. Two rig
daemons of doyle's run on each box (spt-rig-a here, ~/spt-rig-b there, subnet `ws272-rig`);
IGNORE their edge transitions and do NOT stop them.

## SUPERSEDED — the 05:0Xz state, kept for the record

## STATE 2026-09-07 ~05:0Xz — W2 IS COMPLETE AND GATE-READY; ONLY THE BATTERY IS LEFT

Lane: build/ws272-w2, rebased onto main bfb5d58a (hertz's row-46 landed 04:32Z). FIVE commits:
  a629e553 W2 source (written under the cargo hold)
  23b6ec6e the remote arm's wire + owner handler + requester
  0392eadd it compiles: the WAN-ingress caller, its cells, four clippy fixes
  f944b963 the cross-node int arm
  37e31324 GATE FINDING F1 — bind register-on-my-behalf to the user's own words

ALL LOCAL GATES GREEN, each read from cargo's OWN exit file, never a pipeline's:
  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  (after xtask gen: reference.md +73 lines)
  traceable-reqs check ........................ 0 FINDINGS (doc+impl+unit+int on all five)

THE ONLY REMAINING STEP: the TARGETED battery, then the PR, then doyle's gate.
  1. Wait for main run 34083455082's WINDOWS UNIT job to conclude (doyle's ruling (b): one
     battery per box). Linux + lint already green; Windows was still in_progress at 05:0Xz.
  2. Read the disk floor IMMEDIATELY before starting — the runner's own target regrows ~50 GB
     during that job (245 GB free after my reap, 207 GB while it runs).
  3. python .github/ci/ws272-w2.py --run --hold-released   (self-test 6/6; TARGETED nextest with
     a population FLOOR so a filter that selects nothing cannot read as a pass).
  4. Open the PR with PR-BODY-W2.md (already written, at the worktree root). It carries the four
     serve_for refusals in order and the honest note that the int arm's OLD positive was the hole.

F1, FOR THE RECORD (doyle's finding, accepted and built): WEB is default-on inside a subnet, so
the surface gate admits every member and the audience check only proves the endpoint lives on the
asking node — together they let ANY subnet node have this one expose ANY absolute path. The
binding is the owner's OWN MSG_OUT row for the origin short-ID: exists here, is a user-msg, target
== the audience, and the user's words contain the path. Read via msgid::local_msg_out_in, NOT via
the index (the index records where a message is HELD — the target, which for a cross-node send is
not on the sending box).

DISK: the landed ws272-w1 pool was reaped 04:2Xz — 137.33 GB, 17,059 files, classified as a REAL
directory first, inbound reparse sweep clean, CARGO_TARGET_DIR measured empty. Free 110.79 ->
245.41 GB. doyle accepted and recorded it.

CRAFT PIN EARNED THIS SESSION: `rustfmt --edition 2021 --config skip_children=true --emit stdout
<file>` is a PARSE GATE that needs neither cargo nor a pool. Run under a cargo embargo it kept
~1500 blind-written lines down to THREE compile errors, and it caught a botched scripted edit to
cli.rs before it ever reached a compiler.

## SUPERSEDED — the state as of 03:40Z, kept for the record

## STATE 2026-09-07 ~03:40Z (source-first, STILL NOTHING COMPILED)

Doyle 03:28Z: W1 battery ENDED, verdict GREEN at `9489ef60` (my 8d980fdf + hertz's B-witness
fixup) on both boxes. CARGO HOLD CONTINUES — disk, not lane: 27.7 GB free vs CI's 32 GiB floor,
ws272-w1 pool measures 176 GB. He reclaims, pushes 9489ef60 (PR #196 head moves), thin CI ~20 min,
lands ff, THEN I get the pool + cargo (~35 min from 03:28Z). My W2 battery stays TARGETED.
**When the lift comes: re-base this lane onto the landed main (W1 head moved 8d980fdf -> 9489ef60),
then re-claim the ws272-w1 pool from this worktree with `--foreign-pool`.**

STEPS 1-7 WRITTEN (local arms), parse-checked with `rustfmt --config skip_children=true --emit
stdout` (a PARSE gate that needs no cargo and no pool — cheap and it caught nothing, which is the
point of running it after every patch). NOT compiled, NOT tested.

Landed in source this session:
- `spt-proto/event.rs` — EVENT_ATTR_MSG_ID (`msg-id`), EVENT_ATTR_REPLY_TO, EVENT_ATTR_ATTACHMENTS
  + a cell pinning all three sender-authored (not in RECEIVER_COMPOSED_ATTRS) and re-emittable.
- `spt-store/serving.rs` — DEFAULT_ATTACHMENT_TTL_MS (30d), HELPER_ENTRY_TTL_MS (24h),
  `AttachmentRef {name,url,bytes}`, `parse_ttl_ms` (unit REQUIRED) + cell.
- `spt-store/perch.rs` — `owlery_dir_in`.
- `spt-store/msgid.rs` — `resolve_in` (index -> spool -> io-log -> nothing) + `attachments_of`.
- `spt-store/iolog.rs` — additive `msg_id` on IoLogRow (serde default/skip idiom).
- `spt-daemon/iobus.rs` — `IoEvent.msg_id` + `with_msg_id`, carried into the row.
- `spt-daemon/servehost.rs` — `ServeRequest::Attach` (daemon snapshots AND registers under one
  lock; snapshot deleted if either the add or the save fails) + `ServeResult::Attached{entry,bytes}`.
- `spt-daemon/webserve.rs` — ttl expiry 404 (kind-independent, at serve_entry head),
  ServedKind::Attachment served from the snapshot store (`is_snapshot_of` guard), the `/m/<id>`
  facet (HTML + ?json) local arm, `m` PROXIES on the peer arm, `served_entry` split out of
  `served_subject`, `node_hosting_endpoint`.
- `spt-daemon/webproxy.rs` — the AUDIENCE gate beside access_check, node-granularity, same
  `deny_message()` 403 shape, unplaceable audience REFUSES.
- `spt/attach.rs` (new) — resolve_sources (refuse by name, phase A) / register (phase B) /
  attachments_attr + 2 cells.
- `spt/fetchverb.rs` (new) — `spt fetch`, parse (full URL + `<node>/f/<name>`), default dest
  (percent-decoded, separator refused), clobber + --force, temp-then-rename, exit 0/3/1 + 4 cells.
- `spt/msgverb.rs` (new) — `spt msg show <id> [--json]` over `msgid::resolve_in` + 1 cell.
- `spt/cli.rs` — `--attachment` (repeatable) / `--ttl` (requires attachment) / `--reply-to`,
  `SendExtras`, phase A before the seal ceremony and phase B after it, mint-at-commit
  (`MSG_ID_WARN` and carry on if the index write fails), `apply_user_msg_gate_with`, `msg_id_of`
  read off the envelope for BOTH io edges, REQ-SEND-REPLYTO-REMOVE cell REPINNED + 1 new flag cell.
- `spt/serveverb.rs` — `node_and_port()` lifted out, list renders ttl/audience/origin.
- `spt/api/nowsignal.rs` — `Category::FileAccessHelper` in POST_V1, attachment trigger (io-log
  TAIL, 32 rows) + local quoted-path arm + guards, 3 cells, vocabulary cell repinned.
- `traceable-reqs.toml` — REQ-SEND-REPLYTO-REMOVE AMENDED to the semantics it killed.

**CONSEQUENCE TO WATCH WHEN IT FIRST COMPILES (stated, not hidden):** every non-empty send now
carries `msg-id`, so `apply_user_msg_gate` PRE-COMPOSES a typed envelope for EVERY message —
where a plain `spt send` previously handed the raw body to the receiving edge to compose. The
json/seal path has always done exactly this, so the rails support it, but tests that assert a
plain send spools a RAW body will red. That is ADR-0061's own consequence (the id must ride the
delivery envelope), not a slip — but it is the first thing to check when the pool frees.

## NOT BUILT YET — the one carve-out

**#17's REMOTE arm: cross-node register-on-my-behalf.** A user on ANOTHER node quoting a path
needs that path registered THERE (ttl 24h, audience = the receiving endpoint), which needs new
records on the `webmsg` family (`ServeFor` / `ServeForReply`), an owner-side handler beside
`serve_web`, and a requester-side driver — plus a caller, since the now-signal gatherer runs in
the CLI process and has no Brain. DELIBERATELY DEFERRED until the pool frees: it is ~300 lines of
protocol whose only honest proof is the two-host rig, and writing it blind on top of ~1400
already-uncompiled lines is how a lane gets a red it cannot attribute. Everything else in step 7
(the attachment trigger, the same-node arm, every guard) IS built.

## THE REMOTE ARM, designed (build it FIRST when the pool lands — doyle ruled it ORDER, not scope)

Wire (crates/spt-net/src/net/webmsg.rs, the SAME `kind`-tagged family, so an N-1 owner fails the
line decode and the requester's read times out — the degrade W1 already relies on):

- `ServeFor { path: String, audience: String, ttl_ms: u64, origin: Option<String> }` — requester ->
  owner. NO origin-node field, for the reason every record in this file carries none: the owner
  takes the requester's identity from the QUIC handshake, never from payload bytes (KH 7.5).
- `ServeForReply { url: String, served_name: String }` — owner -> requester, then a stream finish.
- A refusal reuses `WebErr { message, refused }` unchanged: same 403-vs-502 split, one shape.

Owner side (webproxy.rs, beside `serve_web`): a stream whose first record is `ServeFor` routes to
`serve_for` instead of the 400 the current `_ =>` arm answers. It:
1. runs the SAME access gate (`surface::WEB`, `InboundClass::Unsolicited`, subject = node scope —
   there is no entry yet to take a subject from);
2. REFUSES unless `node_hosting_endpoint(home, audience)` == the handshake-proven origin node —
   the requester may only ask on behalf of an endpoint IT hosts, which is what stops a third node
   asking us to expose a file to someone else;
3. refuses a path that does not exist, is not absolute, or is not lexically normalized (the
   `serve add` rules, unchanged);
4. registers through `servehost::apply_at` as an ordinary REFERENCE entry (never a snapshot —
   operator-ruled: the user said look at this, not keep this as it was) carrying ttl 24h, the
   audience, and origin = the message short-ID;
5. answers `ServeForReply` with `entry_url(node, port, &entry)`.

Caller: NOT the now-signal gatherer — it runs in the CLI process and has no Brain. The natural
seam is the RECEIVING DAEMON at WAN message ingress (`receive_wan` / the shared injector), where
the sender's node is already proven and the body is in hand: scan the body for quoted paths under
the same `quoted_paths` guards, ask the sender's node to serve each, and record the answer where
the gatherer can read it. Cheapest honest carrier for that record: the entry itself does not live
on this node, so write the returned URLs into the receiving endpoint's io-log as part of the
MSG_IN row's envelope (an `attachments`-shaped attr the existing attachment trigger already reads)
— which collapses the two triggers into ONE gatherer path and is why the gatherer needs no second
arm.

BUILT ALREADY (source, parse-checked, 03:5Xz): the wire records in `spt-net/net/webmsg.rs` (with
the round-trip cell extended), `servehost::ServeRequest::AddScoped` (add + narrow under ONE lock —
two ops would leave a window in which the file is registered and not yet narrowed), the owner-side
`webproxy::serve_for` with its three refusals, and the requester-side `webproxy::request_serve_for`
+ `ServeForOutcome` (which also GENERALIZED `open_and_request` to take a first RECORD rather than a
proxy request — the proxy composes its own `Web` line at the call site now).

STILL TO BUILD: the CALLER, and it carries one decision I did NOT make blind.

**A receiver must not write the `attachments` attr into a delivered envelope.** That was my first
sketch (fold the helper's URLs into the MSG_IN body so the existing attachment trigger reads them
with no second arm) and it is WRONG: `attachments`, `msg-id` and `reply-to` are the SENDER-AUTHORED
class — I documented that and pinned it in a proto cell this same session — so a receiving node
writing one would be exactly the forgery `strip_receiver_composed_attrs` exists to prevent, and the
cell I wrote would (rightly) red.

So the carrier is a RECEIVER-SIDE record instead: the receiving daemon, after a successful
`request_serve_for` at WAN ingress, appends the (message-id, path, url) triple to a small per-perch
store the gatherer reads, and `gather_file_access_helper` grows a third read beside its io-log
scan, delta-keyed on (message, path) exactly as the other two are. Decide the store's shape when
the compiler is available — the candidate is a jsonl beside the perch's io-log, because the
gatherer is already reading that directory and a second sqlite for three fields is not a trade.

Cells to write with it: the owner refusing a ServeFor whose audience it cannot place on the origin
node; the owner registering with ttl 24h + audience + origin; the requester's degrade against an
owner that does not know the record (timeout -> nothing emitted, never a dead line); and the
two-host int cell that closes REQ-NOW-SIGNAL-FILE-ACCESS-HELPER.

## THE INT ARM RESHAPE — doyle's RULING (a), 05:30Z. BUILD THIS FIRST NEXT SESSION.

WHY (a) and not the cheaper two: the int stage is minted as "a remote user's quoted path served to
the named endpoint END TO END". (b) an owner-side direct call with a synthesized origin never
touches the wire and cannot close a stage worded that way — and the four spt-store unit conjunct
cells ALREADY are (b), so they stay and cover that. (c) moving it to twohost_web puts the only int
evidence in a HEAVY rig that runs in golden and the pair rig only, so thin CI on every PR would
carry zero evidence for the helper.

THE SHAPE, as ruled — build it once, in the one-box two-daemon rig:
- On node A, send ONE message AS A USER (a session with no agent identity — that is the attribution
  the owner-side conjunct reads) to an endpoint hosted on B, its body quoting an ABSOLUTE PATH THAT
  EXISTS ON A.
- Wait on B's HELPERLINE STORE for the fetch line. That is the observable proving the ingress caller
  fired AFTER delivery and that request_serve_for went DAEMON TO DAEMON.
- On A, `serve list` shows the entry: kind=File, ttl 24h, audience=that endpoint, origin=the
  short-ID. That is arm (vi).
- B fetches it byte-equal. That is arm (vii).
- Refusal arms (i)-(v) stay, driven the SAME way: five more user/agent messages whose ONE varied
  property is the conjunct under test. NOTE: "no origin" cannot be sent by a user at all, so THAT
  arm becomes an owner-side direct call and SAYS SO IN ITS BREADCRUMB. Each refusal is followed by
  asserting A's registry gained nothing AND B's helperline gained no line.
- Seven F1_ARM breadcrumbs unchanged (they are already written, in .spt/preserved/).

THE FALSIFIER THE GATE WILL RUN as the mutation arm: disable the ingress caller (or make
request_serve_for a no-op) and (vi)/(vii) MUST go RED while the refusal arms stay green. If the arm
can pass with the caller disabled, it is not driving the daemon — build it so that mutation kills it.

FOR THE PR BODY, doyle wants this verbatim: "A deadline I would have to widen to make my own test
shape work is the test being wrong about where the client lives."

ORDER: build (a) -> solo cross_node leg (--no-capture, own .exit, Summary EXACTLY 1, seven arm
lines) -> the four fixed cells -> commit -> PR. Battery #2's other legs STAND. Box is clear:
nothing of doyle's runs, hertz idle, the pool stays mine until I release it.

## NEXT, in order

1. **Send path** (`crates/spt/src/cli.rs`): `--attachment <path>` (repeatable), `--ttl <dur>`,
   `--reply-to <id>`. Mint the id BEFORE composing the envelope (`msgid::mint_in`), spool through
   `spool_message_identified_at` with the SAME `created_at`, add the `attachments` attr and the
   `reply-to` attr to the composed `<EVENT>` via the `apply_user_msg_gate` chokepoint (attrs are an
   open Vec, additive by construction). A missing attachment path refuses the send BY NAME.
2. **`spt fetch`** — new top-level `Cmd::Fetch`. Parse a full URL or `<node>/f/<name>`; reuse
   `webproxy::fetch_via_peer` for a peer and the loopback server for the local node; stream to a
   temp file then rename; exit 0 / 3 (the owner's `refused=true`) / 1.
3. **`spt msg show <id> [--json]`** — new top-level `Cmd::Msg`; `msgid::lookup_in` then
   `spool::read_identified_at` on that owner's perch; render attachment URLs.
4. **io-events** — carry the id on `MSG_IN` / `MSG_OUT`. `IoLogRow` has NO `deny_unknown_fields`
   BY DESIGN and a regression test pins that; add the field with
   `#[serde(default, skip_serializing_if = ...)]`, the established idiom.
5. **webserve** — serve `ServedKind::Attachment` bytes (currently a deliberate 404 near
   `serve_entry`), and build the `/m/<short-id>` facet (HTML + `?json`), which W0 reserved as a
   404-by-name.
6. **webproxy** — the audience check beside the existing `access_check` in `serve_web`, using the
   dispatcher's handshake-proven `origin` node. NOTE THE GAP TO RAISE: the handshake proves a
   NODE, `audience` names an ENDPOINT, and WEB carries no sender stamp yet (ADR-0060). Enforce at
   node granularity by resolving which node hosts the audience endpoint, and say so in the code.
7. **now-signal** — add `Category::FileAccessHelper` to `POST_V1` (never `V1`), tag
   `FILE_ACCESS_HELPER`, `gather_*` in the `gather_last_msgs` shape with a `SeenSet` delta keyed on
   (message, path). An existing test asserts `Category::parse("FILE_ACCESS_HELPER") == None` —
   that cell must flip in the same change. Then the quoted-path detector + guards + the cross-node
   register-on-my-behalf request.
8. **Docs** — `docs-site/src/serving/attachments.md` (new page + SUMMARY entry),
   `serving/overview.md` entry-fields section, `messaging/overview.md` short-ID + reply-to,
   `harness-contract/api.md` now-signal category list.
9. **Tests** — e2e for attachment fetch + audience 403 + short-ID resolution; two-host cells.
10. **Driver** `.github/ci/ws272-w2.py` from the W1 driver with the TARGETED nextest filter.

## Design decision to carry forward: where `/m/<id>` gets the BODY

The index deliberately holds NO body — one message must not have two stored copies that can
disagree. Resolution order for both `spt msg show` and the `/m/<id>` facet:

1. `msgid::lookup_in(home, id)` gives the OWNER (and the parent id, if any).
2. `spool::read_identified_at(owner_perch, id)` — the receiving side, already built.
3. Failing that, the owner's io-log row carrying the id — the SENDING side, which has a `MSG_OUT`
   and no spool row. This needs an additive `msg_id` on `IoLogRow`, using the
   `#[serde(default, skip_serializing_if = ...)]` idiom the file's own regression test pins
   (`a_row_with_an_unknown_field_is_read_not_skipped`; adding `deny_unknown_fields` there is a
   documented landmine — N-1 binaries would silently drop rows).
4. Neither found: 404 / exit 1. Never render a message with an absent body.

The send path's delivery arms (native inject, spool, relay, WAN) all sit under
`spt-msg deliver::send_windowed`, so threading identity into the SPOOL row means touching that
seam; the envelope attr, the io-event field and the node index are all reachable at the send site
without it. Land the send-site half first, then thread the spool row.

## Craft pins for this lane

- Compose peer messages via a quoted heredoc or a file — backticks in a double-quoted body EXECUTE.
- Python on Windows: always `encoding="utf-8"`; a `\` at a line end inside a non-raw triple-quoted
  string is a LINE CONTINUATION and silently eats the newline (it cost one failed patch here).
- `traceable-reqs.toml` titles must contain no `"` — a real quote makes the file unparseable and
  every check after it is vacuous (exit 2).
