# Attachments and `spt fetch`

Sending a file with a message does not push bytes to the receiver. The file is
snapshot into this node's own store, registered for serving, and the message
carries its URL. The receiver decides whether to pull it, and when.

<!-- [doc->REQ-WEB-ATTACHMENT-PULL] -->
## Attaching a file to a message

```console
$ printf 'the report you asked for' | spt send doyle --attachment ./report.md
ATTACHED:report.md: http://localhost:5474/hfenduleam/f/report.md (2481 bytes, ttl 2592000000ms)
SENT:doyle
```

`--attachment` repeats; each file becomes its own registry entry with its own
URL. The message envelope grows an `attachments` attribute carrying, per file,
its served name, its URL and its size in bytes — so a receiving agent can decide
whether to fetch **before** it fetches. A core that has never heard of the
attribute delivers the message unchanged.

An attachment is a **snapshot**, and that is the one place the serving registry
does not resolve at request time:

| | `spt serve add` | `spt send --attachment` |
|---|---|---|
| What is served | the file at its path | a copy taken at send time |
| Editing the source | the reader sees the edit | the reader still sees what was sent |
| Deleting the source | the URL answers 404 | the URL keeps serving |
| Lifetime | until removed | a TTL, 30 days by default |

A message's attachment is as immutable as the message: an edit after the send
must not change what the receiver pulls, and deleting the original must not
turn a delivered link into a dead one.

Two sends of the same filename get two stable, distinct URLs under the
registry's ordinary disambiguation rule (`report.md`, then `report~1.md`), so no
link ever changes meaning.

A missing or unreadable path **refuses the send by name** — nothing is
delivered and nothing is spooled — rather than delivering a message whose link
is dead on arrival:

```console
$ printf 'here' | spt send doyle --attachment ./nope.md
ATTACHMENT_REFUSED:./nope.md: The system cannot find the file specified. (nothing was sent)
```

### Lifetime

`--ttl` sets this send's lifetime and **requires a unit** — `s`, `m`, `h` or
`d`. A bare number is refused rather than guessed, because `30` meaning seconds
when the sender meant days is a deleted attachment.

```console
$ printf 'valid for a day' | spt send doyle --attachment ./build.log --ttl 24h
```

The brain's pulse reaps expired registry entries and logs how many it removed.
For an attachment, it attempts to unlink the core-owned snapshot **before**
saving the registry without that entry. These are not one atomic transaction:
if saving fails after unlinking, the next pulse can finish removing the expired
entry even though its snapshot is already gone. Saving first could instead
leave untracked snapshot bytes that later pulses cannot find.

Only attachment snapshots under `$SPT_HOME/serve/snapshots/` are candidates for
unlinking. Expiry removes a live file or directory reference from the registry
but **never deletes the user's source**. The
[writer-coordination contract](overview.md#register-inspect-remove) keeps the
guard across the reaper's fresh load, snapshot cleanup, and explicit save;
a pass with nothing expired does not save the registry.

An expired entry stops serving the moment it expires, whether or not the
reaper has run yet: between expiry and the next tick the bytes may still be
on disk, and serving them would make the TTL a suggestion.

<!-- [doc->REQ-WEB-FETCH-VERB] -->
## Pulling one: `spt fetch`

```console
$ spt fetch hfenduleam/f/report.md
report.md
$ spt fetch http://localhost:5474/hfenduleam/f/report.md ~/inbox/report.md
/home/reavus/inbox/report.md
```

It accepts a full node-prefixed URL or the bare `<node>/f/<name>` shorthand —
the shorthand is how a link is said between agents, and the host and port belong
to whichever node is doing the fetching. The written path is printed on stdout
so a caller can pipe it.

Without a destination the file lands in the current directory under its served
name. An existing destination is **not** overwritten without `--force`. The body
streams to a temporary file and is renamed into place, so an interrupted or
refused fetch never leaves a truncated file where a later reader would trust it.

The three outcomes are distinct, and the distinction is the point:

| Exit | Meaning |
|---|---|
| `0` | The file was written; its path is on stdout. |
| `3` | The owner **refused** it — an access decision. Retrying is wrong. |
| `1` | Anything else: unreachable owner, not found, a deadline, a local write error. |

Every fetch — local or remote — is served through the local daemon's web
surface, the same path a browser takes, so a fetch of your own node's file
exercises the code a remote fetch will use.

<!-- [doc->REQ-NOW-SIGNAL-FILE-ACCESS-HELPER] -->
## The `FILE_ACCESS_HELPER` signal

An agent does not have to notice an attachment. When a delivered message carries
one, the now-signal hands the agent the exact command to run, taken verbatim
from the envelope:

```text
<FILE_ACCESS_HELPER>
spt fetch http://localhost:5474/kitsubito/f/report.md
</FILE_ACCESS_HELPER>
```

<!-- [doc->REQ-INPUT-PROVENANCE-ACCEPTANCE-REPORT] -->
The same category answers the other way a file arrives: **a user quoting a
filepath while controlling the receiving agent through remote `spt rc`**.
Core binds the existing session-authenticated input report to the broker's live
remote controller when the report arrives. A later controller change cannot
reattribute it. Registration happens on that controller's machine, not by looking
for a similarly named file on the receiving machine.

<!-- [doc->REQ-HELPER-SECOND-AUDIENCE] -->
The file is a **live reference**, not an attachment snapshot: edits are visible
and deletion returns not found. The reference lasts up to **24 hours**. If the
user later hands the same live path to another endpoint, that endpoint joins the
existing entry's audience and receives the **same URL**. No duplicate entry is
created and the original deadline does not move.

<!-- [doc->REQ-HELPER-PROMPT-SHARED-BUDGET] -->
The fetch command normally arrives with the path-bearing prompt after a short
bounded wait for registration. If the owner answers later, the command remains
available on a subsequent signal poll. The wait does not cancel owner work.

<!-- [doc->REQ-HELPER-QUOTED-SPAN-PATHS] -->
- **Absolute or `~`-rooted only.** The owner resolves `~` against its own home.
  A relative path has no anchor on another node. At most **five** paths per report;
  directories receive the same lifetime and audience.
  Paths containing whitespace must be enclosed in matching double quotes, single
  quotes, or backticks: `"C:\My Pictures\a.png"`, `'/home/x/My Pictures/a.png'`,
  or `` `~/a b/c.md` ``. Each quoted span is one path, not whitespace-separated
  fragments; punctuation outside its closing quote is not part of the path.
  Unquoted paths containing whitespace are outside this contract.
  An unmatched opening quote ends extraction; complete paths before it are retained.
- **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. A missing
  file on the bound owner's machine is also silent, with no fallback origin.
- 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, such as an invalid session, uncertain delivery-byte evidence,
  or owner refusal. The broker's **10-second owner-reply timeout** remains named.
- **Once per endpoint, session and filepath.** Rephrasing a prompt does not
  repeat its helper. A new endpoint or session can receive the existing URL;
  neither can extend the live reference's original deadline.
- Core excludes bytes it knows it physically wrote for peer delivery: either an
  exact match or a match after trimming ASCII whitespace from both byte sequences'
  ends. It does not parse message shapes or normalize interior text. This is
  **receipt-seat attribution**, not proof of who typed every byte: external
  automation and native harness re-submission cannot be distinguished beyond the
  current controller seat. No adapter token or integration change is required.

<!-- [doc->REQ-HELPER-SESSION-PATH-DEDUPE] -->
<!-- [doc->REQ-INPUT-RECEIPT-TRIMMED-DEDUPE] -->
An original prompt and its edge-ASCII-trimmed report share the first path
receipt in the same endpoint session, including its controller custody and
deadline. Surrounding prose is not a new file handoff.

<!-- [doc->REQ-HELPER-EMITTED-LINE-DEDUPE] -->
Each poll emits an identical fetch command at most once, even if distinct
attachment or helper records supplied it. All such records are marked seen, so
suppressed duplicates do not appear on the following poll.

Every entry the helper registers is enumerable in `spt serve list` with its
origin and complete audience, so an automatic exposure is exactly as visible
as a deliberate one.

<!-- [doc->REQ-HELPER-SECOND-AUDIENCE] -->
<!-- [doc->REQ-HELPER-PROMPT-SHARED-BUDGET] -->
<!-- [doc->REQ-INPUT-PEER-ENTITY-EXCLUSION] -->
Two-node field acceptance was granted by Doyle on 2026-09-22 for the W2 candidate:
spaced-path fetch, one helper per physical prompt, delivery with the original
prompt, two same-entry/URL audience-admission cases preserving the original
deadline, and exact plus XML-reencoded peer bodies causing **no serve** while a
remote controller remained seated. The seven-cell observations and verified
isolated teardown are preserved in
`.spt/preserved/318/hertz/w2-two-node/DELIVERY.json`; resident daemon records and
firewall output remained unchanged on both nodes. This accepts the tested
dirty-source candidate, not a later commit SHA or the whole release, and does
not turn one node pair's timing into a WAN or numeric hook-latency guarantee.

<!-- [doc->REQ-MSG-SHORT-ID] -->
## Reading a message back

Every message carries a short-ID, and an attachment link is part of what that id
renders — `spt msg show <id>` prints the message with its attachment URLs, and
`/<node>/m/<id>` is the same view in a browser. See
[Messaging](../messaging/overview.md) for the id itself.
