# 0058 — File transfer is pull-model: an attachment is a served file plus a link

Status: accepted (2026-09-06 WEBSERVE grill round 1, releases#272 / #246 / #17)

## Context

Endpoint-to-endpoint file transfer (#246) had three candidate shapes: a push (`spt send
--file`, bytes copied to the receiver through the transfer machinery), a served attachment
(`spt send --attachment`, the file is registered for serving and the message envelope carries
its URL; the receiver pulls if and when it wants), or a dedicated transfer verb outside
messaging. The choice also fixes what the FILE_ACCESS_HELPER now-signal (#17) tells an agent
to do with a file it was handed.

## Decision

**`spt send --attachment` is the primary transfer UX, and it is pull-model.** The file becomes
a serving-registry entry (ADR-0057) and the message carries its node-prefixed URL
(ADR-0056). No unsolicited bytes ever land on a receiver; a human clicks the link, an agent
fetches it. One mechanism serves both audiences. A small companion verb, `spt fetch <url>
[dest]`, gives a receiving agent a local copy in one command — that verb string is what
FILE_ACCESS_HELPER emits.

## Alternatives rejected

- **Push (`--file`).** Delivers bytes the receiver did not ask for, needs receiver-side storage
  policy, and duplicates the serving path for humans. Rejected.
- **Dedicated transfer verb outside messaging.** A file with no message around it loses the
  context a transfer almost always has. Rejected; the attachment rides the message.

## Consequences

- A transferred file is only as available as its owner node; an offline owner means a link
  that waits, which is the honest state and is visible as such.
- The pre-WEBSERVE transfer machinery is superseded by this path (its access surface's fate is
  a separate decision, pending the operator's Q3 confirmation).
- **Reference vs snapshot, and lifetime (round 2, Q8):** a `serve add` entry serves the file AT
  ITS PATH — edits are visible, deletion answers *not found*; an attachment SNAPSHOTS the bytes
  at send time into the registry's own store, because a message's attachment is as immutable
  as the message. `serve add` entries live until removed; attachments carry a time-to-live,
  **default 30 days** (operator-set; `--ttl` per send), reaped by the daemon's existing pulse
  with the reap count logged.

## Amendment 1 — a user-quoted filepath is served automatically for the receiving agent (operator directive, 2026-09-06; recorded by doyle, forks marked)

The operator directed (releases#17): when a user's message quotes a filepath, spt-core
automatically serves that file for a limited time (24h TTL) and exposes it **only to the agent
that received the message**, via the FILE_ACCESS_HELPER now-signal. This widens #17 from
"message carries attachments" to "user quoted a path", and it is built as part of WEBSERVE.

How it composes with this ADR and ADR-0057:

- **Still a registry entry, still pull-model.** The user quoting a path to an agent is the
  deliberate act; core records it as an entry with `ttl = 24h`, `origin = <message
  short-ID>`, and a new **`audience`** field naming the one endpoint allowed to fetch it. The
  entry is enumerable (`spt serve list`) like any other, so "what am I exposing" keeps its one
  answer. Audience is enforced wherever the fetch origin is proven (the cross-node stream's
  handshake, ADR-0060's WEB surface); loopback remains the trusted machine.
- **The signal's output does not change:** the exact `spt fetch <url>` line, once per
  (message, path).
- **The receiver-side record is trimmed by COUNT, not by time.** The helper line the receiving
  node keeps in the endpoint's perch (`helper-fetch.jsonl`) retains the newest 256 records and
  has **no expiry of its own**. A line can therefore outlive the 24h entry it names: it is a
  log of what was offered, never evidence that the link still resolves. Stated here rather
  than fixed with an `at_ms` sweep, because an expiry added without a rig arm to exercise it
  would ship as a claim rather than a behaviour.
- **Guards:** the path must exist on the user's node at signal time; absolute or `~`-rooted
  paths only (a relative path has no anchor); at most 5 per message; a directory registers a
  dir entry under the same TTL and audience.
- **Same node vs remote — historical Am.1 proposal, superseded for USER_INPUT by Am.2.**
  Am.1 proposed a local-readable notice without registration for a same-node user,
  and a cross-node "serve this path for endpoint X, 24h" request authorized by the
  user's attach session for a remote user. Am.2 replaces the local notice with
  silence and uses the existing rc stream for receipt-bound USER_INPUT authority.

**Ruled (operator, 2026-09-06 ~11:00Z):** a helper-registered file is **reference-served** —
the user's live file, edits visible, 404 once deleted — not a snapshot. The user said "look at
this", not "keep this as it was", and a live reference costs no copy. It is therefore a *file*
(or *dir*) entry carrying a TTL and an audience, not an *attachment* entry; the TTL/audience
fields are per-entry and kind-independent.

## Amendment 2 — receipt-seat authority for USER_INPUT (operator re-cut, 2026-09-13; releases#300)

<!-- [doc->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] -->
<!-- [doc->REQ-NOW-SIGNAL-FILE-ACCESS-HELPER] -->
The core-only ruling in [INPUT-PROVENANCE-CONTRACT §8](../INPUT-PROVENANCE-CONTRACT.md#8-ruling-doyle-2026-09-13-re-cut-the-same-day-on-the-operators-direction--core-only-no-token-nothing-asked-of-adapters)
supersedes Amendment 1's local-readable notice and its proposed new WEB request
authority for USER_INPUT. Existing MSG_OUT-backed ServeFor authorization remains
unchanged.

For a path-bearing existing session-authenticated input report, the broker snapshots
its live remote controller connection. No quoted paths means no broker IPC and no
line. An absent hosted session, local or viewer-only seat, or no controller binds
nothing silently, without inspecting receiver-side paths. Requests and replies ride
that same rc stream; the owner derives the audience from its locally established
target. The reference origin is `user-input:<receipt-id>`. The live-reference kind,
bounded 24-hour lifetime, and receiver-side helper record remain the existing
mechanisms.

<!-- [doc->REQ-HELPER-SECOND-AUDIENCE] -->
A later receipt-authorized endpoint joins the same live entry's audience and
receives its existing URL. The owner does not create a second entry or change
the original registration timestamp or deadline. `serve list` exposes the full
audience. The authenticated cross-node fetch still enforces audience at node
granularity: a proven node must host at least one admitted endpoint. This is
not endpoint-level authentication within that node; loopback remains trusted.

<!-- [doc->REQ-HELPER-AUDIENCE-ROLLBACK-FORMAT] -->
On disk, a singleton audience keeps the previous plain-string representation;
only multiple endpoints (or an explicit deny-all empty set) use an array. The
reader accepts both. **Rollback limitation:** 0.71.0 cannot load a registry
containing a multi-audience row, so all registry-backed serving on that node
answers500, including unrelated entries. The 24-hour TTL **does not self-heal**
this outage: the old binary cannot load the registry to reap expired rows.
Recovery is to roll forward, or stop the old daemon and remove the array-form
entry by hand from the backed-up registry while preserving its name-allocation
history. Do not delete the registry or turn its audience into an unrestricted row.

Measured with pinned 0.71.0 binary SHA256
`bc277e6c9f0486e8b2a643a38cade6751685f05838c11fafd22d8dac71d0c182`;
receipt `.spt/preserved/318/todlando-w2/rollback/result.json`. The positive
control served file bytes before the array row. With that row present, both it
and an ordinary sibling returned500 `SERVING_REGISTRY_LOAD_FAIL`; `serve rm`
exited1 and left the registry byte-identical. Reads and shutdown also preserved
the bytes. This is fail-closed rollback with an availability cost, not
mixed-version serving compatibility.

Core excludes peer-delivery text it physically wrote into the session, comparing
exact bytes OR both byte sequences trimmed of ASCII whitespace at their ends.
There is no payload-shape parsing or interior normalization. Fixed-size streaming
evidence is published in physical-write order; unproven writes or exhausted evidence
capacity fail closed rather than evicting evidence or guessing.

<!-- [doc->REQ-HELPER-SESSION-PATH-DEDUPE] -->
<!-- [doc->REQ-INPUT-RECEIPT-TRIMMED-DEDUPE] -->
Helper deduplication is per receiving endpoint, harness session and filepath.
Changing surrounding prose or reporting the original and edge-ASCII-trimmed
prompt cannot renew a live reference or publish another notice. A new endpoint
or session may surface the existing URL. First receipt custody is retained.
Lost or uncertain replies retain a dedup fence: absence of an answer is not
proof that registration did not happen. Missing files are silent, as are the ordinary
non-remote cases above. CLI broker-connect and unanswered-receipt failures are silent
unless `SPT_PUMP_TRACE` enables diagnostics. Named declines apply only to failures
with a remote controller; the broker's 10-second owner-reply timeout remains named.

<!-- [doc->REQ-HELPER-PROMPT-SHARED-BUDGET] -->
The path-bearing hook has **one 500ms internal helper budget**, shared by broker
acknowledgement and owner-result waiting, not a fresh budget after acknowledgement.
The no-path gate performs neither helper IPC nor waiting. On expiry, owner work
continues under the existing 10-second reply deadline and its late helper remains
eligible for the next poll. Public docs promise only a short bounded prompt wait
and later-poll fallback, not a numeric adapter contract.

The choice uses Hertz's ten-sample receiver-to-owner-completion measurement at
`.spt/preserved/318/hertz/helper-field/OWNER-REPORT-TIMING.json`: median76.1516ms,
maximum154.3442ms; 500ms is approximately **3.24 times** that maximum. Timing begins
immediately before native `now-signal` launch and ends at the receiver helper
record's timestamp. It includes CLI startup/IPC, owner registration and both
network directions, not polling-observation delay; it is **not raw wire RTT**.
The sample covers one HFENDULEAM–ENLYZEAM LAN/tailnet pair, first two calls cold,
with W1 jobs=2 and H1 CI load. It is not a WAN or relay guarantee: a slower owner
may miss prompt delivery while retaining next-poll eligibility. Existing total
no-path hook timings do not prove zero added helper work; the no-path effect
gate and its regression do.

<!-- [doc->REQ-API-USER-INPUT-PROVENANCE-DOC] -->
This remains receipt-seat attribution, not reconstructed keystroke provenance.
The existing USER_INPUT report is a claim that the seated user typed the text:
adapters must not forward peer-delivered text as user input. Core's exclusion
of delivery bytes it physically wrote is a backstop, not permission to do so.
No new adapter token, declaration or integration protocol is required.

The separately admitted two-node field leg remains pending, not executed by the
doc/impl/unit lane. Alongside remote serving and local silence, it must keep a remote
controller seated while core physically delivers a path-bearing peer message into
the session and prove that its input report causes no serve. It must also measure
the added hook cost. These are deferred field obligations, not measured results or
an activation of `int`.
