Commit Diff


commit - 32a9a63b07ec43fcd0d3100fadfcea52fab6ed0c
commit + f4e7405e309a640148eef4c8a266ce94c4a23b6d
blob - 49ea1c5d7760eab9c7240161e93c3ba7afa37c7f
blob + 4a2800dd74c9fbf66a7cd527cbb7d4dc8e462aa5
--- CONTEXT.md
+++ CONTEXT.md
@@ -145,7 +145,9 @@ reaches its steady pledge/unveil and replies `READY`.
 `libexec/fugu/`: **fugu-tty** (front end, terminal), **fugu-api**
 (model-credential custodian), **fugu-web** (search custodian and
 hostile-content blast room), **fugu-tool** (tool executor; read-only
-role instance serves subagents), **fugu-editor** (compose spawn room).
+role instance serves subagents), **fugu-artifact** (Owner-private retained
+Tool output, quota, and bounded retrieval), **fugu-editor** (compose spawn
+room).
 
 **Protect list**:
 Owner-configured paths unveiled away from every tool-executing
@@ -155,8 +157,8 @@ role at startup; contents kernel-unreachable, ENOENT-s
 
 **Tool**:
 A capability offered to the model (read, write, edit, multi_edit,
-shell, shell_bg/output/kill, grep, find, ls, web_search, web_fetch,
-http_request, skill, agent).
+shell, shell_bg/output/kill, output_read, grep, find, ls, web_search,
+web_fetch, http_request, skill, agent).
 
 **Subagent**:
 An ephemeral, kernel-enforced read-only conversation spawned by the
@@ -214,8 +216,14 @@ unknown.
 
 ## Relationships
 
-- The **Coordinator** alone talks to every worker; **Workers** never
-  talk to each other (authority map, I10).
+- The **Coordinator** alone exchanges control messages with every worker.
+  **Workers** have no direct imsg channel. The sole direct data plane is one
+  correlated, bounded pipe protocol from the persistent Lead **fugu-tool** to
+  **fugu-artifact**, with its producer endpoint checked and relayed by the
+  Coordinator; a Subagent Role never receives one (authority map, I10).
+  OpenBSD's bidirectional `pipe(2)` extension reports both endpoints as
+  `O_RDWR`, so the one-way flow is enforced by Role and protocol ownership,
+  not by claiming a kernel write-only access mode.
 - The **Owner** is fugu's only human user. The **Invoking user** is that
   person's OS identity for filesystem ownership and legacy match policy, not a
   second domain participant or configurable subscription identity.
@@ -232,6 +240,10 @@ unknown.
 - **Effort** applies unchanged to every Lead Generation and survives Provider
   and model selection. A Subagent receives its inherited or explicit value at
   launch and retains it for every child Generation.
+- A committed **Artifact** belongs to its **Session** and survives resume,
+  **Compaction**, and clear boundaries until quota eviction. The Journal owns
+  the structured id/size record; **fugu-artifact** alone owns raw storage, and
+  `output_read` returns only a bounded id-addressed page.
 
 ## Example dialogue
 
blob - c43906d6ddbf0e54a107b625cd3db44f786c9a02
blob + cf3089f13ac129c627887206cc546b0d709bc8da
--- docs/adr/0001-process-topology.md
+++ docs/adr/0001-process-topology.md
@@ -1,4 +1,4 @@
-# Coordinator plus five single-purpose workers
+# Coordinator plus six single-purpose workers
 
 The security invariants (handoff/invariants.md I1–I15) force several
 separations but leave process count and topology free. We chose a
@@ -13,8 +13,13 @@ secrets), **fugu-api**
 exec after the CA bundle loads), **fugu-web** (search-credential
 custodian; fetch, HTML-to-text, SSRF guard), **fugu-tool** (tool
 executor; project tree unveiled; the same binary runs in a read-only
-role for subagents, I15), and **fugu-editor** (minimal spawn room for
-the external-editor compose key).
+role for subagents, I15), **fugu-artifact** (Owner-private raw Tool-output
+storage, quota accounting, and bounded page retrieval), and **fugu-editor**
+(minimal spawn room for the external-editor compose key).  The
+Artifact Role alone has `rwc` access to the managed Artifact root.  It has no
+project, configuration, Journal, network, tty, credential, or exec authority.
+The Coordinator remains the only Journal writer and retains no Artifact path
+promise.
 
 ## Considered options
 
@@ -24,6 +29,15 @@ the external-editor compose key).
 - *Maximal separation (dedicated journal writer, one executor per
   subagent):* rejected as decorative; no invariant names those walls.
   "Fewer, stronger walls beat many decorative ones" (invariants.md §5).
+- *Coordinator-owned Artifact files:* rejected because the Coordinator's
+  steady pledge intentionally has no path-open promise.  Restoring one would
+  combine the authority hub and private raw-output storage.
+- *Project-local Artifact files:* rejected because model-selected Tools and
+  subprocesses can read the project tree, while retained output can contain
+  secrets and must survive outside the Projection.
+- *A general storage service:* rejected as a shallow ambient capability.  The
+  Artifact Role exposes only fixed Session-bound begin/settle/read/quota
+  exchanges and cannot address arbitrary paths.
 
 ## Consequences
 
@@ -36,3 +50,12 @@ raw untrusted input (vmd-parent precedent).
 Sharing the canonical Turn Implementation with the read-only Subagent Role
 does not move either runtime or add a channel; ADR-0005 records that
 refinement.
+Artifact control and terminal messages remain Coordinator-brokered.  The sole
+Worker-to-Worker data-plane exception is the bounded one-way protocol over a
+pipe whose producer endpoint the Coordinator relays from `fugu-artifact` to
+the Lead `fugu-tool` for one correlated call.  OpenBSD's bidirectional pipe
+extension reports both endpoints as `O_RDWR`; exact validation therefore
+requires a blocking FIFO with that access mode and `CLOEXEC`, while Role
+ownership supplies the one-way flow.  Neither Role uses the endpoint in the
+reverse direction, no endpoint can become a path or general protocol, and the
+read-only Subagent Role never receives one.
blob - 5e7b0ba64fd36ebb0ad2668c447cb5c0028c7267
blob + 755d1afe65074b216dab7ac90eb8fc0a3243c868
--- docs/adr/0003-bespoke-privsep-module.md
+++ docs/adr/0003-bespoke-privsep-module.md
@@ -5,8 +5,14 @@ daemon framework: a full N×N worker mesh, a control s
 `getpeereid` ACLs, `chroot`, `daemon(3)`, config reload, and
 per-process privilege drop to an unprivileged uid. fugu is a
 foreground, single-session, per-invocation program with a
-hub-and-spoke authority map (ADR-0001) — the coordinator talks to
-each worker; workers never talk to each other.
+hub-and-spoke control authority map (ADR-0001) — the coordinator talks to
+each worker; workers never exchange control messages directly.  One later
+load-bearing exception carries no control protocol: for an in-scope Lead Tool
+call, the Coordinator relays a checked pipe producer endpoint from
+`fugu-artifact` to `fugu-tool`, forming a bounded one-way raw-byte protocol.
+The Artifact Role keeps the consumer endpoint, the descriptor is correlated
+to that call, and Subagent Roles never accept it.  OpenBSD exposes both pipe
+endpoints as `O_RDWR`; the Roles never exercise the reverse direction.
 
 We therefore write a small bespoke `priv` module that copies vmd's
 *discipline* — `socketpair`+`fork`+`execve` with the child's channel
@@ -14,8 +20,9 @@ We therefore write a small bespoke `priv` module that 
 terminal business, `closefrom`, `setproctitle`, and deliberate
 reaping — but none of its framework: no control socket, no chroot
 (unveil(2) is fugu's filesystem confinement), no daemonization, no
-mesh. Workers connect to the coordinator alone, which keeps the
-authority map auditable in one header (`proto.h`, I10).
+mesh. Workers' imsg channels connect to the coordinator alone, which keeps the
+authority map auditable in one header (`proto.h`, I10).  The typed Artifact
+pipe relay is also enumerated there and is not a general Worker channel.
 
 ## Considered options
 
blob - da7a636f413daa37ad766b186a121ad2c9e29a87
blob + 1efd39fabc68a246908c0437b89e898c27d81a67
--- docs/adr/0006-typed-imsg-exchanges.md
+++ docs/adr/0006-typed-imsg-exchanges.md
@@ -41,5 +41,17 @@ and stabilizes the events that the canonical Generatio
 The protocol test surface injects failure at every chunk and terminal position
 and traces wrong sizes, out-of-order data, duplicate or missing terminals,
 wrong correlation, overruns, and data after termination.  This decision does
-not change ADR-0001's process topology, ADR-0002's libevent dispatch, or
-ADR-0003's bespoke hub-and-spoke privsep Module.
+not otherwise change ADR-0001's process topology, ADR-0002's libevent
+dispatch, or ADR-0003's bespoke hub-and-spoke privsep Module.
+
+The Artifact stream is the one narrow descriptor-bearing exception to normal
+`imsgev` traffic.  `imsgev` owns an exact typed FD send/receive operation that
+requires the expected message type, correlation, payload size, descriptor
+presence, and close-on-exec state.  `fugu-artifact` creates a pipe and sends
+only its producer endpoint to the Coordinator; the Coordinator validates and
+relays it only to the persistent Lead `fugu-tool`.  OpenBSD's bidirectional
+pipe extension makes the exact endpoint access mode `O_RDWR`, not
+`O_WRONLY`; one-way use is a Role/protocol rule, while the descriptor check
+requires FIFO, blocking, and `CLOEXEC` state.  No storage descriptor or path
+is transferred, ordinary messages still reject descriptors, and every
+unexpected, missing, reordered, or injected descriptor fails closed.
blob - bc52461e2f27a308cb13af3f73f3e345053213db
blob + d8f057b44600da6ccbae0c312dca346b6b931a17
--- docs/design/m10-release.md
+++ docs/design/m10-release.md
@@ -123,7 +123,8 @@ enter either destination.
 The source build installs:
 
 - `${PREFIX}/bin/fugu`, mode 2555 and group `_fugu`;
-- `fugu-tty`, `fugu-api`, `fugu-web`, `fugu-tool`, and `fugu-editor` below
+- `fugu-tty`, `fugu-api`, `fugu-web`, `fugu-tool`, `fugu-artifact`, and
+  `fugu-editor` below
   `${PREFIX}/libexec/fugu/`;
 - `fugu.1` and `fugu.conf.5` in the normal manual hierarchy.
 
blob - e8324d9fadbfe9d0d87233da03609c5638756493
blob + 0e0c253057e1abd97e8b6ddbebf68b4e014e668a
--- docs/design/m3-privsep.md
+++ docs/design/m3-privsep.md
@@ -6,6 +6,15 @@ steady states, the imsg authority map, and the first k
 proofs. Real functionality (turns, tools, streaming) arrives in M4+;
 M3 proves the *walls*.
 
+> **Post-M3 topology amendment:** ADR-0001 now adds the persistent
+> `fugu-artifact` Role for Owner-private retained Tool output. The original
+> milestone counts below are historical M3 scope, not the complete current
+> topology. Artifact control stays hub-and-spoke; one Coordinator-brokered,
+> one-way `fugu-tool` to `fugu-artifact` pipe protocol is the sole direct
+> Worker data plane. OpenBSD reports both pipe endpoints as `O_RDWR`; Role
+> ownership fixes the direction. ADR-0001, ADR-0003, and ADR-0006 define its
+> current authority and typed-descriptor rules.
+
 ## 1. Framework choice: a lean bespoke privsep module
 
 vmd's `proc.c`/`proc.h` is a 730-line daemon framework: a full
@@ -13,8 +22,8 @@ PROC_MAX mesh (every worker connected to every other),
 socket, chroot, `daemon(3)`, config reload, `getpeereid` ACLs. fugu
 is none of those things — it is a foreground, single-session,
 per-invocation program with a **hub-and-spoke** authority map
-(ADR-0001): the coordinator talks to each worker; workers never talk
-to each other.
+(ADR-0001): the coordinator exchanges control with each worker; workers have
+no direct imsg channel.
 
 **Decision:** write a small bespoke `priv` module that copies vmd's
 *discipline* — the exact spawn ordering, fd pinning, `closefrom`,
@@ -34,6 +43,7 @@ bin/fugu  (coordinator, setgid _fugu at exec, sheds gr
   ├─ fork+exec libexec/fugu/fugu-api     persistent, model-key custodian
   ├─ fork+exec libexec/fugu/fugu-web     persistent, search custodian + blast room
   ├─ fork+exec libexec/fugu/fugu-tool    persistent, tool executor
+  ├─ fork+exec libexec/fugu/fugu-artifact persistent, private output store
   ├─ fork+exec libexec/fugu/fugu-tool -r  on demand, read-only, per subagent (M9)
   └─ fork+exec libexec/fugu/fugu-editor  on demand, compose room (M4/M7)
 ```
@@ -112,8 +122,9 @@ coordinator → worker:   FUGU_IMSG_SHUTDOWN
 Every receiver validates type and length before use (I10); a
 malformed message is a clean shutdown, never memory corruption. The
 `imsgbuf_*` post-rewrite API from M-refs §4. Workers accept messages
-**only** from the coordinator fd; there is no worker-to-worker path
-to authorize.
+**only** from the coordinator fd. The later Artifact amendment adds no direct
+imsg path: it adds only the typed, correlated, one-way raw-byte pipe described
+above.
 
 ## 5. Teardown (I12)
 
blob - d246d795a3bbf6ae26e363738c23e91767f82a41
blob + ca9e3a2eff0c37a2e68c89d29cceba02314cb2a0
--- docs/design/m5-tools.md
+++ docs/design/m5-tools.md
@@ -90,14 +90,17 @@ clean per-tool error.
   rename, preserving the existing mode; offered only under
   `allow_write`. `multi_edit` applies 1–64 pairs to an in-memory copy,
   all-or-nothing, naming the failing 1-based index.
-- **shell** — `/bin/ksh -c` in the project dir, combined output
-  capped at 256 KiB, 30 s wall clock then process-group kill.
+- **shell** — `/bin/ksh -c` in the project dir, combined output captured by
+  the private Artifact role under the 16 MiB stream and bounded generated-
+  result policy in behavior.md §5; 30 s wall clock then process-group kill.
 - **grep** / **find** / **ls** — `grep -RnI`, `find`, `ls -la`
-  semantics, 256 KiB caps.
+  semantics under the same Artifact capture policy.
 - **shell_bg** / **shell_output** / **shell_kill** — a job table;
-  each job in its own process group, exempt from the 30 s clock, a
-  256 KiB ring and bounded drain work per pump; `max_bg_jobs` cap
-  returns a clean error rather than queueing.  A direct-child
+  each job in its own process group, exempt from the 30 s clock. Each
+  `shell_output` freezes one immutable Artifact segment and gives a running job
+  a fresh segment; large results expose only a Session-local opaque id that
+  `output_read` can page. The `max_bg_jobs` cap returns a clean error rather
+  than queueing. A direct-child
   supervisor anchors each command group until the worker reaps it,
   preserves the command's exact wait status, and watches a worker-
   liveness pipe, so ordinary group descendants die even if
@@ -105,6 +108,12 @@ clean per-tool error.
   Turn BEGIN/COMMIT/ABORT controls and are killed before an abandoned
   Turn is reported (I12/I14, best-effort — `setsid(2)` escapes).
 
+The initial M5 baseline used direct 256 KiB result caps and a 256 KiB
+background ring. The current Artifact broker supersedes those storage details;
+the confinement, process-group, and Turn-transaction mechanics above remain
+the M5 foundation. The pinned current interface and limits live in
+behavior.md §5.
+
 ### File freshness
 
 A per-process table records dev/ino/size/mtime as `read` returns each
blob - 77a25f0e2f957ef4bd6cbd5c4abc8c83b170b3e5
blob + 0bd4f50cce36898fe62ae23a36fb4db7975a95f6
--- docs/design/m9-subagents.md
+++ docs/design/m9-subagents.md
@@ -4,7 +4,7 @@ Status: implementation and verification complete, 2026
 The OpenBSD `regress/agent` suite passes 155 checks. This slice adds the
 model-facing `agent` tool, its Owner-bounded route palette, fresh read-only
 workers, bounded execution, and the lead-only journal boundary required by
-behavior.md §5.12 and invariant I15.  The combined normal and hardened release
+behavior.md §5.13 and invariant I15.  The combined normal and hardened release
 gates also pass as recorded by `docs/design/m10-release.md`.
 
 ## Surface and routing
blob - e8af57475d109b5c013829db53b40a12098fc695
blob + 95e57a866d9ee863fd031631e45cc6b7811a5124
--- docs/release-live-checks.md
+++ docs/release-live-checks.md
@@ -139,7 +139,7 @@ This target intentionally mutates the clean host.  It 
 leaves it installed.  It verifies:
 
 1. package creation of `_fugu` with GID 992;
-2. the root/group/mode contract for the set-group-ID front binary, all five
+2. the root/group/mode contract for the set-group-ID front binary, all six
    workers, both manuals, and `/etc/fugu.conf.sample`;
 3. installation of every key and the supplied configuration as `root:_fugu`
    mode 0640, while the ordinary user cannot read them directly;
blob - d1b228663ae914c56bda016364fe0548959da2a0
blob + 3499d090bca621c22c42b1227432d2af1e749ce4
--- handoff/README.md
+++ handoff/README.md
@@ -63,7 +63,7 @@ state (HEAD of 2026-06-26, 24 regress suites passing),
 decisions by the author: strict behavior parity, known-flaw fixes
 promoted to requirements (invariants.md I13/I14), and a set of
 deliberate additions beyond parity — the kernel-confined subagent
-tool with per-call model routing (behavior.md §5.12, invariants.md
+tool with per-call model routing (behavior.md §5.13, invariants.md
 I15), and a harness feature set chosen after a gap analysis against
 contemporary agent harnesses: project context files with `#` capture,
 bracketed paste, external-editor compose, a message queue with
blob - f78265824442903f56ca11c1e2cbcc7e11974c1a
blob + 156955f32d3f03a8c55b72479053527f54b833fa
--- handoff/behavior.md
+++ handoff/behavior.md
@@ -407,6 +407,14 @@ engineering). All file paths are interpreted relative 
 directory; absolute paths within it are fine. Every tool result is
 bounded; every boundary value obeys invariants.md I13.
 
+The raw stdout/stderr of `shell`, `grep`, `find`, `ls`, `shell_bg`, and
+`shell_output` is brokered through the private Artifact role, not a Tool-owned
+ring buffer. Each captured stream retains at most 16 MiB. A complete generated
+result is inline only when its serialized document fits 256 KiB; otherwise it
+contains bounded head/tail previews and an opaque, Session-local output id.
+Private paths never cross the role boundary. `output_read` pages that id by raw
+byte offset (64 KiB default, 128 KiB maximum), as UTF-8 text or exact base64.
+
 Always offered:
 
 1. **read** `{path, offset?, limit?}` (required: path) — return file
@@ -423,37 +431,47 @@ Always offered:
    request. Line scanning is length-aware end to end (I13).
    Description steers: read before editing, use `ls` for directories,
    read only the window needed.
-2. **write** `{path, content}` (required: both) — create or fully
+2. **output_read** `{id, offset?, limit?, encoding?}` (required: id) —
+   Lead-only; read one bounded raw-byte page from a Session-local opaque output
+   id.
+   `offset` defaults to 0, `limit` defaults to 64 KiB and is capped at
+   128 KiB, and `encoding` is `text` (default) or `base64`. Text returns only
+   a valid UTF-8 prefix and directs the model to retry the same cursor as
+   base64 when necessary. Results report returned/total bytes, next offset,
+   EOF, and whether the retained object is pending in the current Turn or
+   committed and available. Unknown, expired, malformed, and out-of-range ids
+   are clean generated errors; ids are never paths.
+3. **write** `{path, content}` (required: both) — create or fully
    overwrite; atomic (temp + fsync + rename), preserving an existing
    file's mode; description steers: prefer `edit` for changes, don't
    create unasked documentation. Offered only when `allow_write` is
    on.
-3. **edit** `{path, old_string, new_string}` (required: all) — replace
+4. **edit** `{path, old_string, new_string}` (required: all) — replace
    exactly one occurrence; fail if `old_string` is absent or
    ambiguous; description steers: byte-exact match including
    indentation, keep the match minimal but unique. Offered only when
    `allow_write` is on.
-4. **shell** `{command}` (required: command) — run via `/bin/ksh -c`
-   in the project directory; combined stdout+stderr, capped at
-   256 KiB with a truncation marker; 30-second wall clock, then kill;
+5. **shell** `{command}` (required: command) — run via `/bin/ksh -c`
+   in the project directory; combined stdout+stderr uses the 16 MiB Artifact
+   capture and generated-result policy above; 30-second wall clock, then kill;
    description must state plainly that there is no network access and
    name the web tools as the alternative, and warn about destructive
    commands.
-5. **grep** `{pattern, path?}` — regex search over file contents
+6. **grep** `{pattern, path?}` — regex search over file contents
    (recursive, line-numbered, binary-skipping, `grep -RnI`
-   semantics), output capped at 256 KiB.
-6. **find** `{path?, name?}` — list files, optional glob filter,
-   capped at 256 KiB.
-7. **ls** `{path?}` — long listing (`ls -la` semantics), capped at
-   256 KiB.
+   semantics), under the same Artifact capture policy.
+7. **find** `{path?, name?}` — list files, optional glob filter, under the
+   same Artifact capture policy.
+8. **ls** `{path?}` — long listing (`ls -la` semantics), under the same
+   Artifact capture policy.
 
 Offered while `web_search` is on (default):
 
-8. **web_search** `{query}` (required) — brokered web search returning
+9. **web_search** `{query}` (required) — brokered web search returning
    titles, URLs, snippets (reference: Kagi, 10 results, per-field
    caps). The search provider sits behind a seam: `search_provider`
    names the backend, "kagi" is the one that exists.
-9. **web_fetch** `{url}` (required) — HTTPS-only fetch returning
+10. **web_fetch** `{url}` (required) — HTTPS-only fetch returning
    readable text (HTML reduced to text); raw body cap 2 MiB, returned
    text cap 256 KiB with truncation marker; 30-second deadline; does
    not follow redirects — a redirect status is reported to the model
@@ -462,7 +480,7 @@ Offered while `web_search` is on (default):
 
 Offered only when `http_allow` is configured:
 
-10. **http_request** `{url, method?, headers?, body?}` (required:
+11. **http_request** `{url, method?, headers?, body?}` (required:
     url) — arbitrary-method HTTPS request with model-supplied headers
     and body to hosts passing the allow/block patterns (§6); response
     is the status plus body, non-2xx is a result rather than an
@@ -470,11 +488,11 @@ Offered only when `http_allow` is configured:
 
 Offered when any model-invocable skill exists:
 
-11. **skill** `{name}` (required) — §4.
+12. **skill** `{name}` (required) — §4.
 
 New in this revision, offered while `max_subagents` > 0 (default):
 
-12. **agent** `{prompt, label?, model?}` (required: prompt) — spawn a
+13. **agent** `{prompt, label?, model?}` (required: prompt) — spawn a
     subagent: a fresh conversation with the same system conventions as
     the lead, whose task is the prompt and whose final assistant text
     is returned as the tool result. Constraints (also invariants.md
@@ -523,7 +541,7 @@ New in this revision, offered while `max_subagents` > 
 
 Offered when `allow_write` is on (with write and edit):
 
-13. **multi_edit** `{path, edits}` (required: both) — `edits` is an
+14. **multi_edit** `{path, edits}` (required: both) — `edits` is an
     array of 1–64 `{old_string, new_string}` objects applied in order
     to an in-memory copy of the file, each `old_string` required to
     match exactly once in the intermediate content produced by the
@@ -538,7 +556,7 @@ Offered when `allow_write` is on (with write and edit)
 
 Offered while `max_bg_jobs` > 0 (default), never to subagents:
 
-14. **shell_bg** `{command}` (required) — start a command under
+15. **shell_bg** `{command}` (required) — start a command under
     exactly `shell`'s confinement and working directory, in its own
     process group, exempt from the 30-second clock; returns a job id.
     At the `max_bg_jobs` cap a further call returns a clean error
@@ -547,11 +565,12 @@ Offered while `max_bg_jobs` > 0 (default), never to su
     (builds, test suites); poll with `shell_output` instead of
     re-running; kill jobs no longer needed; the same no-network
     warning as `shell` applies.
-15. **shell_output** `{id}` (required) — return and consume the job's
-    accumulated stdout+stderr (bounded ring buffer, 256 KiB per poll,
-    overflow marked) plus running/exited status and the exit code
-    once exited.
-16. **shell_kill** `{id}` (required) — terminate the job's process
+16. **shell_output** `{id}` (required) — atomically freeze and return the
+    job's accumulated Artifact segment plus running/exited status and the exit
+    code once exited. A still-running job adopts a fresh 16 MiB segment; the
+    frozen segment follows the same inline/opaque-id policy above. Repeated
+    polls therefore advance immutable segments rather than overwriting a ring.
+17. **shell_kill** `{id}` (required) — terminate the job's process
     group.
 
 Background jobs are session-scoped: a job spawned by a turn that
@@ -638,7 +657,7 @@ Keywords (global or in `match` unless noted). Defaults
 | `web_search` yes\|no | offer web_search + web_fetch [yes] |
 | `search_provider` *string* | search backend name [kagi] |
 | `max_subagents` *number* | concurrent subagent cap; 0 withholds the agent tool [4] |
-| `subagent_model` *string* | model id subagents use by default [unset: the lead's active model]; joins the agent tool's per-call palette (§5.12) |
+| `subagent_model` *string* | model id subagents use by default [unset: the lead's active model]; joins the agent tool's per-call palette (§5.13) |
 | `subagent_provider` *string* | name of a configured provider block (or the implicit default) whose route and credential `subagent_model` is resolved against [the lead's active provider] |
 | `subagent_effort` *string* | exact child Generation policy [unset: inherit `effort`; both unset: Provider default] |
 | `max_bg_jobs` *number* | concurrent background shell jobs; 0 withholds shell_bg/shell_output/shell_kill [4] |
blob - 282a7a9294c4609250a0ac8875ecd2a563b5c6e2
blob + 2d2bf3039539bec1b6b06011388c3eae84bc39f2
--- handoff/invariants.md
+++ handoff/invariants.md
@@ -144,7 +144,14 @@ them. MUST throughout.
   peer must produce a clean error or shutdown, not memory corruption.
   IPC uses `imsg(3)`. Whatever the topology, the design must make each
   role's *authority* — who can ask whom for what — explicit and
-  auditable in one place.
+  auditable in one place. Fugu's control topology is hub-and-spoke. Its sole
+  direct Worker data plane is a bounded one-way protocol over a pipe from the
+  persistent Lead Tool Role to the Artifact Role for one correlated output
+  stream; the Coordinator validates and relays the typed producer endpoint,
+  ordinary imsg traffic rejects descriptors, and no storage descriptor or
+  path crosses the boundary. OpenBSD exposes both pipe endpoints as `O_RDWR`,
+  so Role ownership — not a false write-only claim — fixes the direction. The
+  Subagent Tool Role never accepts this stream.
 - **I11 — least linkage.** Each executable links only the libraries its role
   needs: libtls only where TLS is spoken and curses only where the terminal is
   drawn. No ports library or language-runtime dependency may appear anywhere
@@ -156,7 +163,9 @@ them. MUST throughout.
   escape the group — §4 records this non-guarantee).
   Session journals are the Owner's own files, mode 0600, under
   `~/.fugu/sessions/`; the role that writes them is confined to that
-  directory at the time it writes.
+  directory at the time it writes. Retained raw Tool output is stored under
+  the separate Owner-only managed root `~/.fugu/artifacts/`; only the Artifact
+  Role can open it, with mode-0700 Session directories and mode-0600 files.
 
 ### Construction requirements
 
blob - a02345bf80cf8a1ddf2f55a57abc50758ab09fc8
blob + f61986f71509bbd3138c9bcdb0c69c885e25cd03
--- handoff/verification.md
+++ handoff/verification.md
@@ -175,7 +175,10 @@ states the property, not just examples:
   file byte-identical and names the index.
 - **Background jobs:** `max_bg_jobs` honored; jobs die on turn
   failure, cancellation, session end, and drawer switch; unknown and
-  prior-session ids error cleanly; ring-buffer overflow marked.
+  prior-session ids error cleanly. Repeated `shell_output` polls freeze exact
+  immutable Artifact segments, preserve unavailable reservations, and retire
+  available/unavailable spools through exact DROP/ACK, RELEASE, and job-wide
+  CANCEL/ACK races without reusing historical ownership.
 - **Retry policy:** transport retries never occur after the first content delta;
   `Retry-After` clamped to [1, 60] and parsed as untrusted (I13);
   cancellation during backoff leaves the journal clean. Separately, exact
@@ -193,6 +196,16 @@ states the property, not just examples:
 - **Read paging:** offset/limit windows respect the byte cap against
   a newline-free 1 MiB line; totals and next-offset reported; zero
   and negative values rejected.
+- **Artifact output paging and lifetime:** `artifact_result` computes the
+  complete serialized 256 KiB boundary and its +1 transition for printable,
+  escape-heavy, NUL, and invalid-UTF-8/base64 shapes. `artifact_page` pins raw
+  byte cursors, UTF-8 prefix behavior, base64 fidelity, and the 128 KiB page
+  cap. `artifact_store` covers pending/available/expired reads, 16 MiB stream,
+  128 MiB Session and 1 GiB global quotas, reconciliation, eviction, and
+  transaction rollback. `turn` drives a real opaque id through Provider
+  `output_read`, Coordinator parsing/defaults, the Artifact role, an exact
+  NUL/invalid-byte base64 page, and the next Generation; a delayed response is
+  cancelled to prove its exact DATA/READ_RESULT drain precedes rollback.
 - **Edit-diff display:** the display cap and elision marker
   exercised; binary and over-cap content degrades to a summary;
   hostile diff bytes are length-checked at the role boundary (I13);
blob - ea73a765a80c0fbb62e6ba01f1aa49dce9d7442a
blob + 02d3a12f946b134c4795cbd4a232f25c2eca37f0
--- port/productivity/fugu/pkg/PLIST
+++ port/productivity/fugu/pkg/PLIST
@@ -6,6 +6,7 @@
 @group
 libexec/fugu/
 @bin libexec/fugu/fugu-api
+@bin libexec/fugu/fugu-artifact
 @bin libexec/fugu/fugu-editor
 @bin libexec/fugu/fugu-tool
 @bin libexec/fugu/fugu-tty
blob - bb8099191815ac9e733c651c79e7f16af2d0c280
blob + 377dee31422ad5ea47f3498150895217538f7689
--- regress/README
+++ regress/README
@@ -56,7 +56,12 @@ I10 mutual distrust across roles	imsgev (partially con
 					(a hostile second terminal cannot
 					flip a committed turn; a tool
 					subprocess cannot reach the
-					coordinator control fd); sandbox
+					coordinator control fd; normal exact
+					Artifact rotation/retirement and delayed
+					read frames cross the live role mesh);
+					tools (hostile Artifact rotation/drop/cancel
+					mechanics); artifact_store, artifact_page,
+					artifact_lock; sandbox
 					(fdguard: closefrom closes the
 					inherited control fd)
 I11 least linkage			deploy (installed ELF NEEDED sets and
@@ -74,7 +79,11 @@ I13 length-aware strings		buf, json (NUL round-trip), 
 					sse (NUL transparency),
 					anthropic, openai, http, web, output
 					(NUL-bearing framing, headers, bodies,
-					and JSON emission); boundary-wide
+					and JSON emission); artifact_result
+					(whole-document printable/escape/NUL/base64
+					boundaries), artifact_page (raw-byte text and
+					base64 cursors), turn (binary output_read);
+					boundary-wide
 					property grows with each
 					subsystem
 I14 resume fidelity			journal (transactional replay:
@@ -140,6 +149,10 @@ tools (behavior.md 5)			agentcfg (agent schema and rou
 					tools (executors, caps, freshness,
 					NUL rejection, bounded background
 					draining, supervisor/liveness cleanup);
+					artifact_result, artifact_page, artifact_store,
+					artifact_lock (brokered output retention and
+					paging); turn (opaque-id output_read loop and
+					cancelled exact-response drain);
 					web (search/fetch/request policy,
 					caps, credential scoping); turn
 					(full file loop); agent (ephemeral-
@@ -219,7 +232,15 @@ src/common/openai_req.c,
 src/fugu/parse.y, conf.c		conf
 src/fugu/agentcfg.c			agentcfg; agent (live schema/route)
 src/fugu/priv.c, coord.c		sandbox (spawn/handshake/teardown),
-					turn, agent (lead and subagent turn loops)
+					turn (Lead loop and Coordinator Artifact
+					paging seam), agent (subagent turn loop)
+src/fugu/artifact_result.c,
+  artifact_capture.c			artifact_result; tools; turn
+src/fugu/artifact_page.c		artifact_page; turn (real output_read)
+src/fugu/artifact_lock.c,
+  artifact_scope.c,
+  artifact_pipe.c			artifact_lock; sandbox; turn
+src/fugu-artifact (main.c, store.c)	artifact_store; sandbox; turn
 src/fugu/journal.c			journal, turn
 src/fugu/turn_txn.c			turn_txn (pure ordering plus real-Journal
 					integration); turn (cache/SIGINT path)
blob - 48f9dca49e20ad3dff78f57dbfce466d63901fea
blob + 610e69cacd582d67d25bb12662536703ab9cfc8d
--- regress/agent/run.sh
+++ regress/agent/run.sh
@@ -59,7 +59,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv
 
 lx=$dir/libexec/fugu
 mkdir -p "$lx"
-for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do
+for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do
 	b=$root/src/$w/obj/$w
 	[ -x "$b" ] || b=$root/src/$w/$w
 	[ -x "$b" ] || { echo "FAIL - $w not built"; exit 1; }
@@ -208,7 +208,7 @@ done
 ok "agent loop: child receives the complete read-only tool surface" \
     "$readonly_ok"
 forbidden=0
-for name in edit write shell_bg shell_output agent skill http_request; do
+for name in edit write shell_bg shell_output output_read agent skill http_request; do
 	if grep -q "\"name\":\"$name\"" "$dir/child-initial"; then
 		forbidden=1
 	fi
blob - cd2e39e3a1622d80751e8bcebdf12cdda13a70ef
blob + d2c0f0ef3da80f624fbc914e2aec87cec80886c5
--- regress/artifact_lock/artifact_lock_test.c
+++ regress/artifact_lock/artifact_lock_test.c
@@ -7,9 +7,11 @@
  */
 
 #include <sys/types.h>
+#include <sys/file.h>
 #include <sys/stat.h>
 
 #include <err.h>
+#include <errno.h>
 #include <fcntl.h>
 #include <limits.h>
 #include <stdio.h>
@@ -24,7 +26,7 @@ main(void)
 {
 	char base[] = "/tmp/fugu-artifact-lock.XXXXXXXXXX";
 	char session[PATH_MAX], lock[PATH_MAX], wrong[PATH_MAX];
-	int fd, ro, other;
+	int contender, duplicate, fd, ro, third, wrongfd;
 
 	if (mkdtemp(base) == NULL ||
 	    (size_t)snprintf(session, sizeof(session), "%s/session", base) >=
@@ -34,6 +36,12 @@ main(void)
 	    (fd = open(lock, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600)) == -1)
 		err(1, "create lock fixture");
 	CHECK(artifact_lifetime_lock_valid(fd, base, "session"));
+	contender = open(lock, O_RDWR | O_CLOEXEC);
+	CHECK(contender != -1 &&
+	    !artifact_lifetime_lock_valid(contender, base, "session"));
+	duplicate = fcntl(fd, F_DUPFD_CLOEXEC, 0);
+	CHECK(duplicate != -1 &&
+	    artifact_lifetime_lock_valid(duplicate, base, "session"));
 
 	ro = open(lock, O_RDONLY | O_CLOEXEC);
 	CHECK(ro != -1 && !artifact_lifetime_lock_valid(ro, base, "session"));
@@ -42,17 +50,29 @@ main(void)
 	if ((size_t)snprintf(wrong, sizeof(wrong), "%s/wrong", session) >=
 	    sizeof(wrong))
 		errx(1, "wrong-file fixture path is too long");
-	other = open(wrong, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
-	CHECK(other != -1 &&
-	    !artifact_lifetime_lock_valid(other, base, "session"));
-	if (other != -1)
-		close(other);
+	wrongfd = open(wrong, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+	CHECK(wrongfd != -1 &&
+	    !artifact_lifetime_lock_valid(wrongfd, base, "session"));
+	if (wrongfd != -1)
+		close(wrongfd);
 	CHECK(fchmod(fd, 0644) == 0 &&
 	    !artifact_lifetime_lock_valid(fd, base, "session"));
 	CHECK(fchmod(fd, 0600) == 0 &&
 	    artifact_lifetime_lock_valid(fd, base, "session"));
 
+	if (duplicate != -1)
+		close(duplicate);
 	close(fd);
+	CHECK(contender != -1 &&
+	    artifact_lifetime_lock_valid(contender, base, "session"));
+	third = open(lock, O_RDWR | O_CLOEXEC);
+	errno = 0;
+	CHECK(third != -1 && flock(third, LOCK_EX | LOCK_NB) == -1 &&
+	    (errno == EWOULDBLOCK || errno == EAGAIN));
+	if (third != -1)
+		close(third);
+	if (contender != -1)
+		close(contender);
 	(void)unlink(wrong);
 	(void)unlink(lock);
 	(void)rmdir(session);
blob - 65b8df500be7aa4ca4d5e29cc597f05f5c0d63b8
blob + ae44b759a6d7e7e67be0de4889def77679866826
--- regress/artifact_page/artifact_page_test.c
+++ regress/artifact_page/artifact_page_test.c
@@ -14,18 +14,61 @@
 #include "artifact_page.h"
 #include "artifact_result.h"
 #include "buf.h"
+#include "json.h"
 #include "proto.h"
 #include "regress.h"
 
 static const char id[] = "0123456789abcdef0123456789abcdef";
 
+static int
+keys_exact(const struct buf *out, const char *const *want, size_t nwant)
+{
+	unsigned int seen = 0;
+	struct json j;
+	char *key;
+	size_t keylen, i;
+	int at, root, value, n, ok = 0;
+
+	if (nwant > sizeof(seen) * 8 ||
+	    json_parse(&j, out->data, out->len, 0) == -1)
+		return (0);
+	root = json_root(&j);
+	n = json_obj_len(&j, root);
+	if (n < 0 || (size_t)n != nwant)
+		goto done;
+	for (at = 0; at < n; at++) {
+		if (json_obj_item(&j, root, at, &key, &keylen, &value) == -1)
+			goto done;
+		for (i = 0; i < nwant; i++)
+			if (strlen(want[i]) == keylen &&
+			    memcmp(key, want[i], keylen) == 0)
+				break;
+		free(key);
+		if (i == nwant || (seen & (1U << i)) != 0)
+			goto done;
+		seen |= 1U << i;
+	}
+	ok = 1;
+done:
+	json_done(&j);
+	return (ok);
+}
+
 int
 main(void)
 {
+	static const char *const page_keys[] = {
+		"id", "offset", "returned_bytes", "total_bytes", "next_offset",
+		"eof", "encoding", "state", "data", "is_error"
+	};
+	static const char *const retry_keys[] = {
+		"id", "offset", "encoding", "is_error", "error"
+	};
 	struct buf out;
 	struct artifact_read_req req;
 	struct artifact_read_result result;
 	u_char invalid[] = { 0x80, 'x' };
+	u_char invalid_after[] = { 'a', 0x80 };
 	u_char split[] = { 'a', 0xe2, 0x82 };
 	u_char *large;
 	int retry;
@@ -57,21 +100,38 @@ main(void)
 	    out.len <= ARTIFACT_RESULT_MAX &&
 	    strstr(buf_cstr(&out), "\"data\":\"abc\"") != NULL &&
 	    strstr(buf_cstr(&out), "\"eof\":true") != NULL);
+	CHECK(keys_exact(&out, page_keys,
+	    sizeof(page_keys) / sizeof(page_keys[0])));
 
 	CHECK(artifact_page_build(&out, id, 7, 9, 0, invalid,
 	    sizeof(invalid), ARTIFACT_PAGE_TEXT, &retry) == 0 && retry &&
 	    out.len <= ARTIFACT_RESULT_MAX &&
 	    strstr(buf_cstr(&out), "retry") != NULL &&
 	    strstr(buf_cstr(&out), "base64") != NULL);
+	CHECK(keys_exact(&out, retry_keys,
+	    sizeof(retry_keys) / sizeof(retry_keys[0])));
+	CHECK(artifact_page_build(&out, id, 0, sizeof(invalid_after), 0,
+	    invalid_after, sizeof(invalid_after), ARTIFACT_PAGE_TEXT,
+	    &retry) == 0 && !retry &&
+	    strstr(buf_cstr(&out), "\"data\":\"a\"") != NULL &&
+	    strstr(buf_cstr(&out), "\"returned_bytes\":1") != NULL &&
+	    strstr(buf_cstr(&out), "\"next_offset\":1") != NULL);
+	CHECK(artifact_page_build(&out, id, 1, sizeof(invalid_after), 0,
+	    invalid_after + 1, 1, ARTIFACT_PAGE_TEXT, &retry) == 0 && retry);
 
 	/* A valid lead byte split by the requested ceiling advances only through
-	 * the complete prefix; an incomplete sequence at EOF is invalid. */
+	 * the complete prefix.  At EOF that prefix is still returned; retrying at
+	 * the incomplete sequence itself produces the base64 instruction. */
 	CHECK(artifact_page_build(&out, id, 0, 4, 1, split, sizeof(split),
 	    ARTIFACT_PAGE_TEXT, &retry) == 0 && !retry &&
 	    strstr(buf_cstr(&out), "\"returned_bytes\":1") != NULL &&
 	    strstr(buf_cstr(&out), "\"next_offset\":1") != NULL);
 	CHECK(artifact_page_build(&out, id, 0, sizeof(split), 0, split,
-	    sizeof(split), ARTIFACT_PAGE_TEXT, &retry) == 0 && retry);
+	    sizeof(split), ARTIFACT_PAGE_TEXT, &retry) == 0 && !retry &&
+	    strstr(buf_cstr(&out), "\"returned_bytes\":1") != NULL &&
+	    strstr(buf_cstr(&out), "\"next_offset\":1") != NULL);
+	CHECK(artifact_page_build(&out, id, 1, sizeof(split), 0, split + 1, 2,
+	    ARTIFACT_PAGE_TEXT, &retry) == 0 && retry);
 
 	large = malloc(FUGU_ARTIFACT_PAGE_MAX);
 	CHECK(large != NULL);
blob - 006eb8ff4b201d90e5b8013013e710f4d499ff8e
blob + e9e8de2e750b837e6cfee7a4a5018a4684f8cc2b
--- regress/artifact_result/Makefile
+++ regress/artifact_result/Makefile
@@ -1,5 +1,6 @@
 PROG=	artifact_result_test
-SRCS=	artifact_result_test.c artifact_result.c artifact_capture.c json.c \
+SRCS=	artifact_result_test.c artifact_result.c artifact_capture.c \
+	artifact_scope.c json.c \
 	utf8.c buf.c log.c xmalloc.c
 
 CFLAGS+=	-I${.CURDIR}/../../src/fugu
blob - d407d2e47701c030541091407f2567fb2a235b32
blob + 1090a5bf0d5dc1f8c9e76e79baba43a936f5db9a
--- regress/artifact_result/artifact_result_test.c
+++ regress/artifact_result/artifact_result_test.c
@@ -16,6 +16,7 @@
 
 #include "artifact_result.h"
 #include "artifact_capture.h"
+#include "artifact_scope.h"
 #include "buf.h"
 #include "json.h"
 #include "log.h"
@@ -41,6 +42,32 @@ field_count(const struct json *j, int obj, const char 
 	return (count);
 }
 
+static int
+keys_exact(const struct json *j, int obj, const char *const *want,
+    size_t nwant)
+{
+	unsigned int seen = 0;
+	char *key;
+	size_t keylen, i;
+	int at, value, n = json_obj_len(j, obj);
+
+	if (n < 0 || (size_t)n != nwant || nwant > sizeof(seen) * 8)
+		return (0);
+	for (at = 0; at < n; at++) {
+		if (json_obj_item(j, obj, at, &key, &keylen, &value) == -1)
+			return (0);
+		for (i = 0; i < nwant; i++)
+			if (strlen(want[i]) == keylen &&
+			    memcmp(key, want[i], keylen) == 0)
+				break;
+		free(key);
+		if (i == nwant || (seen & (1U << i)) != 0)
+			return (0);
+		seen |= 1U << i;
+	}
+	return (1);
+}
+
 static char *
 string_field(const struct json *j, int root, const char *name, size_t *len)
 {
@@ -50,6 +77,12 @@ string_field(const struct json *j, int root, const cha
 static void
 test_inline_and_forgery(void)
 {
+	static const char *const keys[] = {
+		"preview_head", "preview_tail", "observed_bytes",
+		"retained_bytes", "preview_abbreviated",
+		"artifact_cap_reached", "termination", "preview_encoding",
+		"is_error", "exit_status"
+	};
 	static const char raw[] = "ok\n\",\"id\":\"forged\",\"is_error\":true";
 	struct artifact_result in;
 	struct json j;
@@ -70,6 +103,7 @@ test_inline_and_forgery(void)
 	CHECK(out.len <= ARTIFACT_RESULT_MAX);
 	CHECK(json_parse(&j, out.data, out.len, 0) == 0);
 	root = json_root(&j);
+	CHECK(keys_exact(&j, root, keys, sizeof(keys) / sizeof(keys[0])));
 	CHECK(json_obj_get(&j, root, "id") == -1);
 	CHECK(field_count(&j, root, "is_error") == 1);
 	CHECK(field_count(&j, root, "observed_bytes") == 1);
@@ -85,6 +119,12 @@ static void
 test_encoded_budgets_and_id(void)
 {
 	static const char id[] = "0123456789abcdef0123456789abcdef";
+	static const char *const keys[] = {
+		"id", "preview_head", "preview_tail", "observed_bytes",
+		"retained_bytes", "preview_abbreviated",
+		"artifact_cap_reached", "termination", "preview_encoding",
+		"is_error", "exit_status"
+	};
 	struct artifact_result in;
 	struct json j;
 	struct buf raw, out;
@@ -107,6 +147,7 @@ test_encoded_budgets_and_id(void)
 	CHECK(out.len <= ARTIFACT_RESULT_MAX);
 	CHECK(json_parse(&j, out.data, out.len, 0) == 0);
 	root = json_root(&j);
+	CHECK(keys_exact(&j, root, keys, sizeof(keys) / sizeof(keys[0])));
 	head = string_field(&j, root, "preview_head", &hlen);
 	tail = string_field(&j, root, "preview_tail", &tlen);
 	encoding = string_field(&j, root, "preview_encoding", &elen);
@@ -183,6 +224,83 @@ test_boundary_selection(void)
 }
 
 static void
+test_exact_inline_boundaries(void)
+{
+	static const struct {
+		const char *name;
+		u_char byte;
+		const char *encoding;
+		size_t encoded_step;
+	} shapes[] = {
+		{ "printable", 'x', "utf-8", 1 },
+		{ "escape-heavy", '\1', "utf-8", 6 },
+		{ "NUL", '\0', "utf-8", 6 },
+		{ "invalid UTF-8/base64", 0xff, "base64", 4 }
+	};
+	struct artifact_result in;
+	struct json j;
+	struct buf out;
+	char *encoding;
+	u_char *raw;
+	size_t elen, hi, i, lo, mid;
+	int root;
+
+	raw = malloc(ARTIFACT_RESULT_MAX + 1);
+	CHECK(raw != NULL);
+	if (raw == NULL)
+		return;
+	buf_init(&out);
+	for (i = 0; i < sizeof(shapes) / sizeof(shapes[0]); i++) {
+		memset(raw, shapes[i].byte, ARTIFACT_RESULT_MAX + 1);
+		lo = 0;
+		hi = ARTIFACT_RESULT_MAX + 1;
+		while (lo + 1 < hi) {
+			mid = lo + (hi - lo) / 2;
+			if (artifact_result_inline_fits(raw, mid))
+				lo = mid;
+			else
+				hi = mid;
+		}
+		CHECK(lo != 0 && artifact_result_inline_fits(raw, lo));
+		CHECK(!artifact_result_inline_fits(raw, lo + 1));
+
+		memset(&in, 0, sizeof(in));
+		in.raw = raw;
+		in.raw_len = lo;
+		in.observed_bytes = lo;
+		in.retained_bytes = lo;
+		in.termination = ARTIFACT_TERM_EXIT;
+		CHECK(artifact_result_build(&out, &in) == 0 &&
+		    out.len <= ARTIFACT_RESULT_MAX &&
+		    ARTIFACT_RESULT_MAX - out.len < shapes[i].encoded_step);
+		CHECK(json_parse(&j, out.data, out.len, 0) == 0);
+		root = json_root(&j);
+		CHECK(json_get_bool(&j, json_obj_get(&j, root,
+		    "preview_abbreviated")) == 0);
+		encoding = string_field(&j, root, "preview_encoding", &elen);
+		CHECK(encoding != NULL && elen == strlen(shapes[i].encoding) &&
+		    memcmp(encoding, shapes[i].encoding, elen) == 0);
+		free(encoding);
+		json_done(&j);
+
+		in.raw_len = lo + 1;
+		in.observed_bytes = lo + 1;
+		in.retained_bytes = lo + 1;
+		in.id = good_id;
+		CHECK(artifact_result_build(&out, &in) == 0 &&
+		    out.len <= ARTIFACT_RESULT_MAX);
+		CHECK(json_parse(&j, out.data, out.len, 0) == 0);
+		root = json_root(&j);
+		CHECK(json_get_bool(&j, json_obj_get(&j, root,
+		    "preview_abbreviated")) == 1);
+		json_done(&j);
+		(void)shapes[i].name;
+	}
+	buf_free(&out);
+	free(raw);
+}
+
+static void
 test_error_metadata_is_not_spoofable(void)
 {
 	static const char detail[] = "spawn failed before capture";
@@ -394,6 +512,25 @@ test_streamed_equivalence(void)
 	free(raw);
 }
 
+static void
+test_capture_scope_is_exact(void)
+{
+	static const char *included[] = {
+		"shell", "grep", "find", "ls", "shell_bg", "shell_output"
+	};
+	static const char *excluded[] = {
+		"read", "write", "edit", "multi_edit", "shell_kill",
+		"output_read", "web_search", "web_fetch", "http_request",
+		"skill", "agent"
+	};
+	size_t i;
+
+	for (i = 0; i < sizeof(included) / sizeof(included[0]); i++)
+		CHECK(artifact_capture_tool_name(included[i]));
+	for (i = 0; i < sizeof(excluded) / sizeof(excluded[0]); i++)
+		CHECK(!artifact_capture_tool_name(excluded[i]));
+}
+
 int
 main(void)
 {
@@ -402,11 +539,13 @@ main(void)
 	test_encoded_budgets_and_id();
 	test_binary_base64();
 	test_boundary_selection();
+	test_exact_inline_boundaries();
 	test_error_metadata_is_not_spoofable();
 	test_json_integer_range();
 	test_empty_streaming_capture_is_inline();
 	test_fixed_wire_id_validation();
 	test_escape_heavy_error_budget();
 	test_streamed_equivalence();
+	test_capture_scope_is_exact();
 	REGRESS_END();
 }
blob - fb4bcb5e128ddfb756d0b0a464368112e5d40e92
blob + ecc4e21e840b8a0c66405e3b405f5bb7d4cd9d6c
--- regress/artifact_store/Makefile
+++ regress/artifact_store/Makefile
@@ -1,6 +1,6 @@
 PROG=	artifact_store_test
-SRCS=	artifact_store_test.c store.c artifact_result.c json.c buf.c log.c \
-	xmalloc.c
+SRCS=	artifact_store_test.c store.c artifact_lock.c artifact_result.c json.c \
+	buf.c log.c xmalloc.c
 
 CFLAGS+=	-I${.CURDIR}/../../src/fugu \
 		-I${.CURDIR}/../../src/fugu-artifact
blob - ab2a9add66a46151f9a1347bf300b2b084f6ad34
blob + 00ff8f0b07ef8f1fe47232c0131c252992729b38
--- regress/artifact_store/artifact_store_test.c
+++ regress/artifact_store/artifact_store_test.c
@@ -23,6 +23,7 @@
 #include <unistd.h>
 
 #include "artifact_result.h"
+#include "artifact_lock.h"
 #include "buf.h"
 #include "proto.h"
 #include "store.h"
@@ -155,6 +156,19 @@ begin_stream_kind(struct artifact_store *store, uint32
 	    write_fd, stream));
 }
 
+static struct artifact_store *
+open_ready(const char *root, const char *session, int fresh)
+{
+	struct artifact_store *store;
+
+	store = artifact_store_open(root, session, fresh);
+	if (store != NULL && artifact_store_reconcile_finish(store) == -1) {
+		artifact_store_close(store);
+		store = NULL;
+	}
+	return (store);
+}
+
 static int
 begin_stream(struct artifact_store *store, uint32_t correlation,
     struct artifact_pending **pending, int *write_fd,
@@ -196,24 +210,80 @@ write_and_drain(struct artifact_pending *pending, int 
 	return (rc);
 }
 
+#ifdef ARTIFACT_STORE_TESTING
 static void
+test_reconciliation_state(const char *root)
+{
+	struct artifact_store *store;
+	struct artifact_pending *pending;
+	struct artifact_stream stream;
+	struct artifact_record record;
+	struct artifact_read_req req;
+	struct artifact_read_result result;
+	struct buf out;
+	char id[FUGU_ARTIFACT_ID_BUFSZ];
+	int write_fd = -1;
+
+	store = artifact_store_open(root, "reconcile-state", 1);
+	CHECK(store != NULL);
+	memset(&record, 0, sizeof(record));
+	id_for(42, id);
+	strlcpy(record.id, id, sizeof(record.id));
+	CHECK(artifact_store_turn_begin(store) == -1);
+	buf_init(&out);
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.id, id, sizeof(req.id));
+	CHECK(artifact_store_read(store, &req, &out, &result) == -1);
+	CHECK(artifact_store_reconcile_add(store, &record) == 0);
+	artifact_store_test_fail(ARTIFACT_STORE_FAULT_RECONCILE_FSYNC);
+	CHECK(artifact_store_reconcile_finish(store) == -1);
+	/* Failed END leaves the initial phase retryable. */
+	CHECK(artifact_store_reconcile_add(store, &record) == 0);
+	CHECK(artifact_store_reconcile_finish(store) == 0);
+	CHECK(artifact_store_reconcile_finish(store) == -1);
+	CHECK(artifact_store_reconcile_add(store, &record) == -1);
+	CHECK(artifact_store_read(store, &req, &out, &result) == 0 &&
+	    result.status == ARTIFACT_READ_EXPIRED);
+
+	CHECK(artifact_store_turn_begin(store) == 0);
+	CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0);
+	/* Reconciliation can never be restarted around live staging. */
+	CHECK(artifact_store_reconcile_add(store, &record) == -1);
+	CHECK(artifact_store_reconcile_finish(store) == -1);
+	close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	buf_free(&out);
+	artifact_store_close(store);
+}
+#endif
+
+static void
 test_lock_and_permissions(const char *root)
 {
 	struct artifact_store *store, *again;
 	struct stat st;
-	char path[PATH_MAX];
+	char path[PATH_MAX], alias[PATH_MAX], target[PATH_MAX], outside[PATH_MAX];
 	pid_t pid;
 	int p[2], status;
 	char result;
 
 	store = artifact_store_open(root, "lock-session", 1);
 	CHECK(store != NULL);
+	CHECK(stat(root, &st) == 0 && st.st_uid == geteuid() &&
+	    (st.st_mode & 0777) == 0700);
 	path_for(path, sizeof(path), root, "lock-session", ".session.lock");
-	CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0600);
+	CHECK(stat(path, &st) == 0 && st.st_uid == geteuid() &&
+	    (st.st_mode & 0777) == 0600 && st.st_nlink == 1);
 	if ((size_t)snprintf(path, sizeof(path), "%s/%s", root,
 	    "lock-session") >= sizeof(path))
 		errx(1, "lock path");
-	CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0700);
+	CHECK(stat(path, &st) == 0 && st.st_uid == geteuid() &&
+	    (st.st_mode & 0777) == 0700);
+	CHECK(artifact_store_open(root, "", 0) == NULL && errno == EINVAL);
+	CHECK(artifact_store_open(root, "../foreign", 0) == NULL &&
+	    errno == EINVAL);
+	CHECK(artifact_store_open(root, "bad/session", 0) == NULL &&
+	    errno == EINVAL);
 	CHECK(artifact_store_open(root, "lock-session", 1) == NULL &&
 	    errno == EEXIST);
 	if (pipe(p) == -1)
@@ -241,7 +311,8 @@ test_lock_and_permissions(const char *root)
 	{
 		int lock_copy = artifact_store_lock_dup(store);
 
-		CHECK(lock_copy != -1);
+		CHECK(lock_copy != -1 && artifact_lifetime_lock_valid(lock_copy,
+		    root, "lock-session"));
 		artifact_store_close(store);
 		CHECK(artifact_store_open(root, "lock-session", 0) == NULL &&
 		    errno == EWOULDBLOCK);
@@ -250,6 +321,41 @@ test_lock_and_permissions(const char *root)
 	again = artifact_store_open(root, "lock-session", 0);
 	CHECK(again != NULL);
 	artifact_store_close(again);
+
+	/* Managed roots and Session names are exact directories, never aliases. */
+	CHECK((size_t)snprintf(alias, sizeof(alias), "%s-root-alias", root) <
+	    sizeof(alias));
+	CHECK((size_t)snprintf(target, sizeof(target), "%s-root-target", root) <
+	    sizeof(target));
+	CHECK(mkdir(target, 0700) == 0 && symlink(target, alias) == 0);
+	CHECK(artifact_store_open(alias, "session", 1) == NULL);
+
+	CHECK((size_t)snprintf(alias, sizeof(alias), "%s-session-alias", root) <
+	    sizeof(alias));
+	CHECK((size_t)snprintf(target, sizeof(target), "%s-session-target", root) <
+	    sizeof(target));
+	CHECK(mkdir(alias, 0700) == 0 && mkdir(target, 0700) == 0);
+	CHECK((size_t)snprintf(path, sizeof(path), "%s/session", alias) <
+	    sizeof(path) && symlink(target, path) == 0);
+	CHECK(artifact_store_open(alias, "session", 0) == NULL);
+
+	/* A hard-linked lock could alias another Owner file; exact nlink rejects it. */
+	CHECK((size_t)snprintf(alias, sizeof(alias), "%s-lock-alias", root) <
+	    sizeof(alias));
+	mkdir_session(alias, "session");
+	path_for(path, sizeof(path), alias, "session", ".session.lock");
+	CHECK((size_t)snprintf(outside, sizeof(outside), "%s/outside", alias) <
+	    sizeof(outside));
+	{
+		int outside_fd = open(outside,
+		    O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+
+		CHECK(outside_fd != -1);
+		if (outside_fd != -1)
+			close(outside_fd);
+	}
+	CHECK(unlink(path) == 0 && link(outside, path) == 0);
+	CHECK(artifact_store_open(alias, "session", 0) == NULL);
 }
 
 static void
@@ -265,7 +371,7 @@ test_cap_and_transaction(const char *root)
 	char committed[FUGU_ARTIFACT_ID_BUFSZ], aborted[FUGU_ARTIFACT_ID_BUFSZ];
 	int write_fd, rc;
 
-	store = artifact_store_open(root, "states", 1);
+	store = open_ready(root, "states", 1);
 	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
 	CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 &&
 	    stream.status == ARTIFACT_STREAM_AVAILABLE);
@@ -330,6 +436,58 @@ test_cap_and_transaction(const char *root)
 }
 
 static void
+test_session_read_isolation(const char *root)
+{
+	struct artifact_store *one, *two;
+	struct artifact_pending *pending;
+	struct artifact_stream stream;
+	struct artifact_terminal terminal;
+	struct artifact_record record;
+	struct artifact_read_req req;
+	struct artifact_read_result result;
+	struct buf out;
+	struct stat st;
+	char id[FUGU_ARTIFACT_ID_BUFSZ], source[PATH_MAX], alias[PATH_MAX];
+	int write_fd;
+
+	one = open_ready(root, "isolation-one", 1);
+	CHECK(one != NULL && artifact_store_turn_begin(one) == 0);
+	CHECK(begin_stream(one, 1, &pending, &write_fd, &stream) == 0 &&
+	    write_and_drain(pending, write_fd, 3, &terminal) == 1 &&
+	    artifact_store_turn_prepare(one) == 0 &&
+	    artifact_store_turn_commit(one) == 0);
+	strlcpy(id, stream.id, sizeof(id));
+	path_for(source, sizeof(source), root, "isolation-one", id);
+	CHECK(stat(source, &st) == 0 && st.st_uid == geteuid() &&
+	    (st.st_mode & 0777) == 0600 && st.st_nlink == 1);
+
+	two = artifact_store_open(root, "isolation-two", 1);
+	buf_init(&out);
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.id, id, sizeof(req.id));
+	req.limit = 3;
+	/* Even corrupted replay knowledge cannot turn a same-Owner file alias
+	 * into a cross-Session read: final symlinks and hard links are rejected. */
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	record.retained_bytes = 3;
+	CHECK(artifact_store_reconcile_add(two, &record) == 0 &&
+	    artifact_store_reconcile_finish(two) == 0);
+	path_for(alias, sizeof(alias), root, "isolation-two", id);
+	CHECK(symlink(source, alias) == 0 &&
+	    artifact_store_read(two, &req, &out, &result) == 0 &&
+	    result.status == ARTIFACT_READ_ERROR && out.len == 0 &&
+	    unlink(alias) == 0);
+	CHECK(link(source, alias) == 0 &&
+	    artifact_store_read(two, &req, &out, &result) == 0 &&
+	    result.status == ARTIFACT_READ_ERROR && out.len == 0 &&
+	    unlink(alias) == 0);
+	buf_free(&out);
+	artifact_store_close(two);
+	artifact_store_close(one);
+}
+
+static void
 test_spool_ownership(const char *root)
 {
 	struct artifact_store *store;
@@ -342,7 +500,7 @@ test_spool_ownership(const char *root)
 	char detached[FUGU_ARTIFACT_ID_BUFSZ];
 	int write_fd;
 
-	store = artifact_store_open(root, "spools", 1);
+	store = open_ready(root, "spools", 1);
 	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
 	CHECK(begin_stream_kind(store, 1, ARTIFACT_BEGIN_SPOOL, &pending,
 	    &write_fd, &stream) == 0);
@@ -386,7 +544,7 @@ test_correlation_keys(const char *root)
 	struct artifact_terminal terminal;
 	int fdone, fdtwo, ignored_fd = -1;
 
-	store = artifact_store_open(root, "correlations", 1);
+	store = open_ready(root, "correlations", 1);
 	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
 	CHECK(begin_stream_kind(store, 10, ARTIFACT_BEGIN_SPOOL, &one,
 	    &fdone, &sone) == 0);
@@ -425,7 +583,7 @@ test_publication_collision_and_prepared_close(const ch
 	char id[FUGU_ARTIFACT_ID_BUFSZ], path[PATH_MAX], byte = 0;
 	int fd, write_fd;
 
-	store = artifact_store_open(root, "publication", 1);
+	store = open_ready(root, "publication", 1);
 	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
 	CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 &&
 	    write_and_drain(pending, write_fd, 1, &terminal) == 1);
@@ -483,7 +641,7 @@ test_terminal_storage_errors(const char *root)
 
 	for (i = 0; i < sizeof(faults) / sizeof(faults[0]); i++) {
 		(void)snprintf(session, sizeof(session), "terminal-%zu", i);
-		store = artifact_store_open(root, session, 1);
+		store = open_ready(root, session, 1);
 		CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
 		CHECK(begin_stream_kind(store, 1, ARTIFACT_BEGIN_SPOOL,
 		    &pending, &write_fd, &stream) == 0);
@@ -520,7 +678,7 @@ test_abort_retry(const char *root)
 
 	for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
 		(void)snprintf(session, sizeof(session), "abort-%zu", i);
-		store = artifact_store_open(root, session, 1);
+		store = open_ready(root, session, 1);
 		CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
 		CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 &&
 		    write_and_drain(pending, write_fd, 1, &terminal) == 1);
@@ -541,6 +699,399 @@ test_abort_retry(const char *root)
 }
 
 static void
+test_partial_publication_cleanup(const char *root)
+{
+	struct artifact_store *store;
+	struct artifact_pending *one, *two;
+	struct artifact_stream sone, stwo;
+	struct artifact_terminal terminal;
+	char pending[64], order[64], path[PATH_MAX], byte = 0;
+	int fd, fdone, fdtwo;
+
+	store = open_ready(root, "partial-publish-abort", 1);
+	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
+	CHECK(begin_stream(store, 1, &one, &fdone, &sone) == 0 &&
+	    write_and_drain(one, fdone, 3, &terminal) == 1);
+	artifact_store_test_fail(
+	    ARTIFACT_STORE_FAULT_PREPARE_STAGING_UNLINK);
+	CHECK(artifact_store_turn_prepare(store) == -1);
+	(void)snprintf(pending, sizeof(pending), ".pending-%s", sone.id);
+	(void)snprintf(order, sizeof(order), ".order-%s", sone.id);
+	CHECK(exists(root, "partial-publish-abort", sone.id) &&
+	    exists(root, "partial-publish-abort", pending));
+	CHECK(artifact_store_turn_abort(store) == 0);
+	CHECK(!exists(root, "partial-publish-abort", sone.id) &&
+	    !exists(root, "partial-publish-abort", pending) &&
+	    !exists(root, "partial-publish-abort", order));
+	artifact_store_close(store);
+
+	/* A later collision can follow an earlier successful publication in the
+	 * same prepare.  Shutdown rolls the whole partial prepare back without
+	 * deleting the colliding pre-existing final. */
+	store = open_ready(root, "partial-publish-close", 1);
+	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
+	CHECK(begin_stream(store, 1, &one, &fdone, &sone) == 0 &&
+	    begin_stream(store, 2, &two, &fdtwo, &stwo) == 0);
+	CHECK(write_and_drain(one, fdone, 1, &terminal) == 1 &&
+	    write_and_drain(two, fdtwo, 2, &terminal) == 1);
+	path_for(path, sizeof(path), root, "partial-publish-close", sone.id);
+	fd = open(path, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+	CHECK(fd != -1 && write(fd, "Z", 1) == 1);
+	if (fd != -1)
+		close(fd);
+	CHECK(artifact_store_turn_prepare(store) == -1 &&
+	    exists(root, "partial-publish-close", stwo.id));
+	artifact_store_close(store);
+	(void)snprintf(pending, sizeof(pending), ".pending-%s", sone.id);
+	CHECK(!exists(root, "partial-publish-close", pending) &&
+	    !exists(root, "partial-publish-close", stwo.id));
+	fd = open(path, O_RDONLY | O_CLOEXEC);
+	CHECK(fd != -1 && read(fd, &byte, 1) == 1 && byte == 'Z');
+	if (fd != -1)
+		close(fd);
+}
+
+static void
+test_metadata_validation_and_nonblocking_opens(const char *root)
+{
+	struct artifact_store *store;
+	struct artifact_pending *pending = NULL;
+	struct artifact_stream stream;
+	struct artifact_terminal terminal;
+	struct artifact_record record;
+	struct artifact_read_req req;
+	struct stat st;
+	char subroot[PATH_MAX], path[PATH_MAX], outside[PATH_MAX];
+	char id[FUGU_ARTIFACT_ID_BUFSZ], order[64];
+	uint64_t marker = UINT64_C(0x1122334455667788), observed = 0;
+	pid_t pid;
+	mode_t oldmask;
+	int fd, status, write_fd = -1, rc;
+
+	CHECK(mkdir(root, 0700) == 0);
+
+	/* Creation reaches exact private modes even when the inherited Owner
+	 * umask masks every permission bit. */
+	(void)snprintf(subroot, sizeof(subroot), "%s/creation-umask", root);
+	store = open_ready(subroot, "session", 1);
+	CHECK(store != NULL && artifact_store_turn_begin(store) == 0 &&
+	    begin_stream(store, 1, &pending, &write_fd, &stream) == 0);
+	oldmask = umask(0777);
+	rc = write_and_drain(pending, write_fd, 1, &terminal);
+	(void)umask(oldmask);
+	CHECK(rc == 1 && terminal.status == ARTIFACT_STORE_OK);
+	(void)snprintf(path, sizeof(path), "%s/.sequence", subroot);
+	CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0600);
+	(void)snprintf(path, sizeof(path), "%s/session/.order-%s", subroot,
+	    stream.id);
+	CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0600);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	/* A preplanted hard link must not let sequence allocation chmod or write
+	 * an arbitrary Owner file. */
+	(void)snprintf(subroot, sizeof(subroot), "%s/sequence", root);
+	store = open_ready(subroot, "session", 1);
+	CHECK(store != NULL && artifact_store_turn_begin(store) == 0 &&
+	    begin_stream(store, 1, &pending, &write_fd, &stream) == 0);
+	(void)snprintf(outside, sizeof(outside), "%s/sequence-outside", root);
+	fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
+	CHECK(fd != -1 && fchmod(fd, 0644) == 0 &&
+	    write(fd, &marker, sizeof(marker)) == (ssize_t)sizeof(marker));
+	if (fd != -1)
+		close(fd);
+	(void)snprintf(path, sizeof(path), "%s/.sequence", subroot);
+	CHECK(link(outside, path) == 0);
+	rc = write_and_drain(pending, write_fd, 1, &terminal);
+	CHECK(rc == -1 && terminal.status == ARTIFACT_STORE_ERROR);
+	fd = open(outside, O_RDONLY | O_CLOEXEC);
+	CHECK(fd != -1 && fstat(fd, &st) == 0 &&
+	    (st.st_mode & 0777) == 0644 &&
+	    pread(fd, &observed, sizeof(observed), 0) ==
+	    (ssize_t)sizeof(observed) && observed == marker);
+	if (fd != -1)
+		close(fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	/* Existing order metadata is exact, including mode and link count. */
+	(void)snprintf(subroot, sizeof(subroot), "%s/order-mode", root);
+	store = artifact_store_open(subroot, "session", 1);
+	id_for(100, id);
+	make_sparse(subroot, "session", id, 1, 1);
+	make_order(subroot, "session", id, 1);
+	(void)snprintf(order, sizeof(order), ".order-%s", id);
+	path_for(path, sizeof(path), subroot, "session", order);
+	CHECK(chmod(path, 0644) == 0);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	record.retained_bytes = 1;
+	CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 &&
+	    artifact_store_reconcile_finish(store) == 0 &&
+	    artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE);
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	(void)snprintf(subroot, sizeof(subroot), "%s/order-link", root);
+	store = artifact_store_open(subroot, "session", 1);
+	id_for(101, id);
+	make_sparse(subroot, "session", id, 1, 1);
+	(void)snprintf(outside, sizeof(outside), "%s/order-outside", root);
+	fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+	CHECK(fd != -1 && write(fd, &marker, sizeof(marker)) ==
+	    (ssize_t)sizeof(marker));
+	if (fd != -1)
+		close(fd);
+	(void)snprintf(order, sizeof(order), ".order-%s", id);
+	path_for(path, sizeof(path), subroot, "session", order);
+	CHECK(link(outside, path) == 0);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	record.retained_bytes = 1;
+	CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 &&
+	    artifact_store_reconcile_finish(store) == 0 &&
+	    artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE);
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	/* FIFOs at read-only path boundaries return an error promptly rather than
+	 * waiting for an attacker-controlled peer. */
+	(void)snprintf(subroot, sizeof(subroot), "%s/order-fifo", root);
+	store = artifact_store_open(subroot, "session", 1);
+	id_for(102, id);
+	make_sparse(subroot, "session", id, 1, 1);
+	(void)snprintf(order, sizeof(order), ".order-%s", id);
+	path_for(path, sizeof(path), subroot, "session", order);
+	CHECK(mkfifo(path, 0600) == 0);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	record.retained_bytes = 1;
+	CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 &&
+	    artifact_store_reconcile_finish(store) == 0 &&
+	    artifact_store_turn_begin(store) == 0);
+	pid = fork();
+	if (pid == 0) {
+		alarm(2);
+		rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+		_exit(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE ?
+		    0 : 1);
+	}
+	CHECK(pid != -1 && waitpid(pid, &status, 0) == pid &&
+	    WIFEXITED(status) && WEXITSTATUS(status) == 0);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	(void)snprintf(subroot, sizeof(subroot), "%s/final-fifo", root);
+	store = artifact_store_open(subroot, "session", 1);
+	id_for(103, id);
+	path_for(path, sizeof(path), subroot, "session", id);
+	CHECK(mkfifo(path, 0600) == 0);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 &&
+	    artifact_store_reconcile_finish(store) == 0);
+	pid = fork();
+	if (pid == 0) {
+		struct artifact_read_result result;
+		struct buf out;
+
+		alarm(2);
+		buf_init(&out);
+		memset(&req, 0, sizeof(req));
+		strlcpy(req.id, id, sizeof(req.id));
+		_exit(artifact_store_read(store, &req, &out, &result) == 0 &&
+		    result.status == ARTIFACT_READ_ERROR ? 0 : 1);
+	}
+	CHECK(pid != -1 && waitpid(pid, &status, 0) == pid &&
+	    WIFEXITED(status) && WEXITSTATUS(status) == 0);
+	artifact_store_close(store);
+}
+
+static void
+fill_sparse_session(const char *root, const char *session, unsigned int base)
+{
+	char id[FUGU_ARTIFACT_ID_BUFSZ];
+	int i;
+
+	for (i = 0; i < 64; i++) {
+		id_for(base + (unsigned int)i, id);
+		make_sparse(root, session, id, ARTIFACT_RAW_MAX, 1 + i);
+	}
+}
+
+static void
+test_quota_metadata_and_inactive_cleanup(const char *root)
+{
+	struct artifact_store *store;
+	struct artifact_pending *pending = NULL;
+	struct artifact_stream stream;
+	struct artifact_record record;
+	struct stat st;
+	char subroot[PATH_MAX], path[PATH_MAX], outside[PATH_MAX];
+	char id[FUGU_ARTIFACT_ID_BUFSZ], first[FUGU_ARTIFACT_ID_BUFSZ];
+	char pending_name[64], order_name[64];
+	int fd, write_fd = -1, rc;
+
+	CHECK(mkdir(root, 0700) == 0);
+
+	/* Every final counted by quota has the same exact private-file shape as
+	 * a final admitted by output_read. */
+	(void)snprintf(subroot, sizeof(subroot), "%s/final-mode", root);
+	store = artifact_store_open(subroot, "current", 1);
+	id_for(200, id);
+	make_sparse(subroot, "current", id, 1, 1);
+	path_for(path, sizeof(path), subroot, "current", id);
+	CHECK(chmod(path, 0644) == 0);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	record.retained_bytes = 1;
+	CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 &&
+	    artifact_store_reconcile_finish(store) == 0 &&
+	    artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    exists(subroot, "current", id));
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	(void)snprintf(subroot, sizeof(subroot), "%s/final-link", root);
+	store = artifact_store_open(subroot, "current", 1);
+	id_for(201, id);
+	(void)snprintf(outside, sizeof(outside), "%s/final-outside", root);
+	fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+	CHECK(fd != -1 && write(fd, "X", 1) == 1);
+	if (fd != -1)
+		close(fd);
+	path_for(path, sizeof(path), subroot, "current", id);
+	CHECK(link(outside, path) == 0);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	record.retained_bytes = 1;
+	CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 &&
+	    artifact_store_reconcile_finish(store) == 0 &&
+	    artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    exists(subroot, "current", id));
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	(void)snprintf(subroot, sizeof(subroot), "%s/final-oversize", root);
+	store = artifact_store_open(subroot, "current", 1);
+	id_for(202, id);
+	make_sparse(subroot, "current", id, ARTIFACT_RAW_MAX + 1, 1);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	record.retained_bytes = ARTIFACT_RAW_MAX;
+	CHECK(store != NULL && artifact_store_reconcile_add(store, &record) == 0 &&
+	    artifact_store_reconcile_finish(store) == 0 &&
+	    artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    exists(subroot, "current", id));
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	/* An inactive crash between final link and staging unlink is repaired as
+	 * a distinct synchronized pass; its valid final and FIFO order survive. */
+	(void)snprintf(subroot, sizeof(subroot), "%s/crash-link", root);
+	store = open_ready(subroot, "current", 1);
+	mkdir_session(subroot, "inactive");
+	id_for(300, id);
+	make_sparse(subroot, "inactive", id, 1, 1);
+	path_for(path, sizeof(path), subroot, "inactive", id);
+	(void)snprintf(pending_name, sizeof(pending_name), ".pending-%s", id);
+	path_for(outside, sizeof(outside), subroot, "inactive", pending_name);
+	CHECK(link(path, outside) == 0);
+	make_order(subroot, "inactive", id, 1);
+	(void)snprintf(order_name, sizeof(order_name), ".order-%s", id);
+	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
+	artifact_store_test_fail(ARTIFACT_STORE_FAULT_INACTIVE_CLEANUP_FSYNC);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE);
+	if (rc == 0)
+		close(write_fd);
+	rc = begin_stream(store, 2, &pending, &write_fd, &stream);
+	CHECK(rc == 0 && !exists(subroot, "inactive", pending_name) &&
+	    exists(subroot, "inactive", id) &&
+	    exists(subroot, "inactive", order_name));
+	path_for(path, sizeof(path), subroot, "inactive", id);
+	CHECK(stat(path, &st) == 0 && st.st_nlink == 1);
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	/* Invalid Session directories, names, and lifetime locks are never made
+	 * eviction candidates even under global pressure. */
+	(void)snprintf(subroot, sizeof(subroot), "%s/session-mode", root);
+	store = open_ready(subroot, "current", 1);
+	mkdir_session(subroot, "inactive");
+	fill_sparse_session(subroot, "inactive", 1000);
+	(void)snprintf(path, sizeof(path), "%s/inactive", subroot);
+	CHECK(chmod(path, 0755) == 0);
+	id_for(1000, first);
+	CHECK(artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    exists(subroot, "inactive", first));
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	(void)snprintf(subroot, sizeof(subroot), "%s/session-name", root);
+	store = open_ready(subroot, "current", 1);
+	mkdir_session(subroot, "invalid_name");
+	fill_sparse_session(subroot, "invalid_name", 2000);
+	id_for(2000, first);
+	CHECK(artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    exists(subroot, "invalid_name", first));
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+
+	(void)snprintf(subroot, sizeof(subroot), "%s/session-lock", root);
+	store = open_ready(subroot, "current", 1);
+	mkdir_session(subroot, "inactive");
+	fill_sparse_session(subroot, "inactive", 3000);
+	path_for(path, sizeof(path), subroot, "inactive", ".session.lock");
+	CHECK(unlink(path) == 0);
+	(void)snprintf(outside, sizeof(outside), "%s/lock-outside", root);
+	fd = open(outside, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+	CHECK(fd != -1);
+	if (fd != -1)
+		close(fd);
+	CHECK(link(outside, path) == 0);
+	id_for(3000, first);
+	CHECK(artifact_store_turn_begin(store) == 0);
+	rc = begin_stream(store, 1, &pending, &write_fd, &stream);
+	CHECK(rc == 1 && stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    exists(subroot, "inactive", first));
+	if (rc == 0)
+		close(write_fd);
+	CHECK(artifact_store_turn_abort(store) == 0);
+	artifact_store_close(store);
+}
+
+static void
 test_discard_retry_does_not_close_reused_fd(const char *root)
 {
 	struct artifact_store *store;
@@ -550,7 +1101,7 @@ test_discard_retry_does_not_close_reused_fd(const char
 	char path[PATH_MAX];
 	int sentinel, write_fd;
 
-	store = artifact_store_open(root, "discard-retry", 1);
+	store = open_ready(root, "discard-retry", 1);
 	CHECK(store != NULL && artifact_store_turn_begin(store) == 0);
 	CHECK(begin_stream(store, 1, &pending, &write_fd, &stream) == 0 &&
 	    write_and_drain(pending, write_fd, 1, &terminal) == 1);
@@ -642,7 +1193,7 @@ spawn_quota_child(const char *root, unsigned int sessi
 		close(result[0]);
 		close(stop[1]);
 		(void)snprintf(session, sizeof(session), "quota-%u", session_no);
-		store = artifact_store_open(root, session, 1);
+		store = open_ready(root, session, 1);
 		if (store == NULL || artifact_store_turn_begin(store) == -1)
 			_exit(2);
 		(void)write(result[1], &ch, 1);
@@ -737,9 +1288,9 @@ test_active_session_protection(const char *root)
 	struct artifact_pending *pending;
 	struct artifact_stream stream;
 	char id[FUGU_ARTIFACT_ID_BUFSZ], first[FUGU_ARTIFACT_ID_BUFSZ];
-	int write_fd, i;
+	int write_fd = -1, i;
 
-	current = artifact_store_open(root, "current", 1);
+	current = open_ready(root, "current", 1);
 	CHECK(current != NULL);
 	mkdir_session(root, "active");
 	id_for(900, id);
@@ -799,7 +1350,7 @@ test_global_quota_current_first_and_sync(const char *r
 	char inactive_id[FUGU_ARTIFACT_ID_BUFSZ];
 	int i, write_fd = -1;
 
-	current = artifact_store_open(root, "current", 1);
+	current = open_ready(root, "current", 1);
 	CHECK(current != NULL);
 	mkdir_session(root, "inactive");
 	id_for(2000, current_id);
@@ -841,6 +1392,8 @@ main(void)
 {
 	char base[] = "/tmp/fugu-artifact-store.XXXXXXXXXX";
 	char root[PATH_MAX], quota[PATH_MAX], active[PATH_MAX], priority[PATH_MAX];
+	char metadata[PATH_MAX];
+	char quota_metadata[PATH_MAX];
 
 	if (mkdtemp(base) == NULL)
 		err(1, "mkdtemp");
@@ -848,15 +1401,25 @@ main(void)
 	(void)snprintf(quota, sizeof(quota), "%s/quota", base);
 	(void)snprintf(active, sizeof(active), "%s/active-root", base);
 	(void)snprintf(priority, sizeof(priority), "%s/priority", base);
+	(void)snprintf(metadata, sizeof(metadata), "%s/metadata", base);
+	(void)snprintf(quota_metadata, sizeof(quota_metadata),
+	    "%s/quota-metadata", base);
 	test_lock_and_permissions(root);
+#ifdef ARTIFACT_STORE_TESTING
+	test_reconciliation_state(root);
+#endif
 	test_cap_and_transaction(root);
+	test_session_read_isolation(root);
 	test_spool_ownership(root);
 	test_correlation_keys(root);
 	test_publication_collision_and_prepared_close(root);
 #ifdef ARTIFACT_STORE_TESTING
 	test_terminal_storage_errors(root);
 	test_abort_retry(root);
+	test_partial_publication_cleanup(root);
 	test_discard_retry_does_not_close_reused_fd(root);
+	test_metadata_validation_and_nonblocking_opens(metadata);
+	test_quota_metadata_and_inactive_cleanup(quota_metadata);
 #endif
 	test_reconcile_and_fifo(root);
 	test_concurrent_global_quota(quota);
blob - a7bebf14d9e15b9ab01591791f276c797059063e
blob + 056675df21b268029c06cafe23d7c4eef2f1562f
--- regress/deploy/deploy_test.sh
+++ regress/deploy/deploy_test.sh
@@ -52,7 +52,7 @@ ok "layout: fugu is under PREFIX/bin" $?
 test "$(stat -f %Mp%Lp "$prefix/bin/fugu" 2>/dev/null)" = 2555
 ok "layout: fugu is installed mode 2555" $?
 
-workers='fugu-api fugu-editor fugu-tool fugu-tty fugu-web'
+workers='fugu-api fugu-artifact fugu-editor fugu-tool fugu-tty fugu-web'
 missing=0
 badmode=0
 for worker in $workers; do
@@ -60,7 +60,7 @@ for worker in $workers; do
 	test "$(stat -f %Lp "$prefix/libexec/fugu/$worker" 2>/dev/null)" = \
 	    555 || badmode=1
 done
-ok "layout: all five workers are under PREFIX/libexec/fugu" "$missing"
+ok "layout: all six workers are under PREFIX/libexec/fugu" "$missing"
 ok "layout: workers are ordinary mode-0555 executables" "$badmode"
 
 test -f "$prefix/man/man1/fugu.1" && \
@@ -120,8 +120,9 @@ test "$(needed "$prefix/libexec/fugu/fugu-api")" = "c 
     test "$(needed "$prefix/libexec/fugu/fugu-web")" = "c event tls util"
 ok "linkage: only network custodians add tls" $?
 test "$(needed "$prefix/libexec/fugu/fugu-tool")" = "c event util" && \
+    test "$(needed "$prefix/libexec/fugu/fugu-artifact")" = "c event util" && \
     test "$(needed "$prefix/libexec/fugu/fugu-editor")" = "c event util"
-ok "linkage: tool and editor map no tls or curses" $?
+ok "linkage: tool, artifact, and editor map no tls or curses" $?
 
 for binary in "$prefix/bin/fugu" "$prefix/libexec/fugu"/*; do
 	needed "$binary"
blob - b6c459d77ce6bd555f8e8e3dca2f41fdab2e4bfd
blob + ec864a3123dbff7336905ecbdae77c205e6a3c04
--- regress/journal/journal_test.c
+++ regress/journal/journal_test.c
@@ -457,6 +457,60 @@ test_epoch_retained_transactions(void)
 }
 
 static void
+test_artifact_survives_epoch_resets(void)
+{
+	static const char artifact_id[] =
+	    "0123456789abcdef0123456789abcdef";
+	struct system_epoch start, compact, clear;
+	struct artifact_record record;
+	struct journal_state state;
+	struct journal *j;
+	char id[SESSION_ID_MAX];
+
+	make_epoch(&start, SYSTEM_EPOCH_START, NULL, 0);
+	make_epoch(&compact, SYSTEM_EPOCH_COMPACT, "retained", 8);
+	make_epoch(&clear, SYSTEM_EPOCH_CLEAR, NULL, 0);
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, artifact_id, sizeof(record.id));
+	record.retained_bytes = 12345;
+
+	j = journal_create(dir, id, sizeof(id));
+	CHECK(j != NULL &&
+	    journal_system_epoch(j, &start, NULL, NULL, 0) == 0);
+	journal_turn_begin(j, 1);
+	journal_artifact(j, &record);
+	append_text_message(j, ROLE_USER, "artifact owner", 14);
+	append_text_message(j, ROLE_ASSISTANT, "artifact result", 15);
+	journal_turn_end(j, 1, TURN_OK);
+	CHECK(journal_system_epoch(j, &compact, NULL, NULL, 0) == 0);
+	journal_close(j);
+
+	journal_state_init(&state);
+	CHECK(journal_restore(dir, id, &state) == 0 &&
+	    state.have_epoch && state.epoch.reason == SYSTEM_EPOCH_COMPACT &&
+	    state.nartifacts == 1 &&
+	    strcmp(state.artifacts[0].id, artifact_id) == 0 &&
+	    state.artifacts[0].retained_bytes == 12345);
+	journal_state_free(&state);
+
+	j = journal_open(dir, id);
+	CHECK(j != NULL &&
+	    journal_system_epoch(j, &clear, NULL, NULL, 0) == 0);
+	journal_close(j);
+	journal_state_init(&state);
+	CHECK(journal_restore(dir, id, &state) == 0 &&
+	    state.have_epoch && state.epoch.reason == SYSTEM_EPOCH_CLEAR &&
+	    count(&state.messages) == 0 && state.nartifacts == 1 &&
+	    strcmp(state.artifacts[0].id, artifact_id) == 0 &&
+	    state.artifacts[0].retained_bytes == 12345);
+	journal_state_free(&state);
+
+	system_epoch_free(&clear);
+	system_epoch_free(&compact);
+	system_epoch_free(&start);
+}
+
+static void
 test_roundtrip(void)
 {
 	struct journal	*j;
@@ -2040,6 +2094,7 @@ main(void)
 	test_roundtrip();
 	test_system_epoch_roundtrip();
 	test_epoch_retained_transactions();
+	test_artifact_survives_epoch_resets();
 	test_epoch_reasons_and_legacy();
 	test_epoch_malformed_and_bounds();
 	test_resume_fidelity();
blob - 1096a8e195db44bbff50609519907374c52cbb0d
blob + 4a6a0b39bb69dddf944dcf9d6b66b9cfdd438de2
--- regress/openai/run.sh
+++ regress/openai/run.sh
@@ -28,7 +28,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv
 
 lx=$dir/libexec/fugu
 mkdir -p "$lx" "$dir/home"
-for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do
+for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do
 	b=$root/src/$w/obj/$w
 	[ -x "$b" ] || b=$root/src/$w/$w
 	[ -x "$b" ] || { echo "FAIL - $w not built"; exit 1; }
blob - 75a7e52480e04e44c8e98f6876b9d9a109855c56
blob + f7eba65eae05bc013ec6f6c18228eef209360fa7
--- regress/print/run.sh
+++ regress/print/run.sh
@@ -48,7 +48,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv
 
 lx=$dir/libexec/fugu
 mkdir -p "$lx"
-for worker in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do
+for worker in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do
 	bin=$root/src/$worker/obj/$worker
 	[ -x "$bin" ] || bin=$root/src/$worker/$worker
 	[ -x "$bin" ] || { echo "FAIL - $worker not built"; exit 1; }
blob - 4b9b9728466d28a8ebfb55104ce6689584699477
blob + 0c97435af5abb2ff9df5621fc131d3b2ae65447a
--- regress/sandbox/Makefile
+++ regress/sandbox/Makefile
@@ -1,5 +1,5 @@
 PROG=	harness
-SRCS=	harness.c priv.c log.c xmalloc.c
+SRCS=	harness.c priv.c artifact_lock.c artifact_pipe.c log.c xmalloc.c
 NOMAN=	yes
 
 .PATH:		${.CURDIR}/../../src/fugu ${.CURDIR}/../../src/common
blob - fbde8b9464fceb0681f58f70fde07b85dd63e73e
blob + 2b83b3a84f03afd1881af57da7905d73d2712fa4
--- regress/sandbox/harness.c
+++ regress/sandbox/harness.c
@@ -37,10 +37,12 @@
  */
 
 #include <sys/types.h>
+#include <sys/stat.h>
 #include <sys/wait.h>
 
 #include <err.h>
 #include <errno.h>
+#include <fcntl.h>
 #include <imsg.h>
 #include <poll.h>
 #include <signal.h>
@@ -52,13 +54,28 @@
 #include "log.h"
 #include "priv.h"
 #include "proto.h"
+#include "artifact_lock.h"
+#include "artifact_pipe.h"
 
 #define TEST_BAD_HELLO		(-1)
 #define TEST_BAD_HELLO_ID	(-2)
 #define TEST_BAD_PING		(-3)
 #define TEST_BAD_PING_ID	(-4)
 #define TEST_CLAUDE_MODELS	(-5)
+#define TEST_TOOL_DESCRIPTOR_READ (-6)
+#define TEST_TOOL_SHELL_NO_STREAM (-7)
+#define TEST_TOOL_REORDERED_STREAM (-8)
+#define TEST_TOOL_SHELL		(-9)
+#define TEST_TOOL_FREEZE_SWAP	(-10)
+#define TEST_TOOL_FREEZE_TERMINAL (-11)
+#define TEST_TOOL_FREEZE_UNAVAILABLE (-12)
+#define TEST_TOOL_FREEZE_CANCEL	(-13)
 
+static const char artifact_id_one[] =
+    "0123456789abcdef0123456789abcdef";
+static const char artifact_id_two[] =
+    "fedcba9876543210fedcba9876543210";
+
 /* Blocking imsg read of one message of the expected type. */
 static int
 expect_msg(struct imsgbuf *ibuf, uint32_t want, struct imsg *out)
@@ -71,6 +88,8 @@ expect_msg(struct imsgbuf *ibuf, uint32_t want, struct
 			return (-1);
 		if (n != 0) {
 			if (imsg_get_type(out) != want) {
+				warnx("expected imsg %u, got %u", want,
+				    imsg_get_type(out));
 				imsg_free(out);
 				return (-1);
 			}
@@ -78,10 +97,14 @@ expect_msg(struct imsgbuf *ibuf, uint32_t want, struct
 		}
 		pfd.fd = ibuf->fd;
 		pfd.events = POLLIN;
-		if (poll(&pfd, 1, 5000) <= 0)
+		if (poll(&pfd, 1, 5000) <= 0) {
+			warnx("timed out waiting for imsg %u", want);
 			return (-1);		/* worker died or timed out */
-		if ((n = imsgbuf_read(ibuf)) == -1 || n == 0)
+		}
+		if ((n = imsgbuf_read(ibuf)) == -1 || n == 0) {
+			warnx("worker closed while waiting for imsg %u", want);
 			return (-1);
+		}
 	}
 }
 
@@ -102,6 +125,409 @@ send_msg(struct imsgbuf *ibuf, uint32_t type, const vo
 	send_msg_id(ibuf, type, 0, data, len);
 }
 
+static int
+artifact_id_valid(const char id[FUGU_ARTIFACT_ID_BUFSZ])
+{
+	size_t i;
+
+	for (i = 0; i < FUGU_ARTIFACT_ID_LEN; i++)
+		if (!((id[i] >= '0' && id[i] <= '9') ||
+		    (id[i] >= 'a' && id[i] <= 'f')))
+			return (0);
+	return (id[FUGU_ARTIFACT_ID_LEN] == '\0');
+}
+
+/* Exercise the real post-lockdown SCM_RIGHTS stream exchange. */
+static void
+exercise_artifact_stream(struct imsgbuf *ibuf)
+{
+	static const char probe[] = "artifact-pipe-probe";
+	struct artifact_turn_ack ack;
+	struct artifact_begin begin;
+	struct artifact_stream stream;
+	struct artifact_terminal terminal;
+	struct imsg imsg;
+	int fd, fdflags;
+
+	send_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_BEGIN, NULL, 0);
+	if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_ACK, &imsg) == -1 ||
+	    imsg_get_len(&imsg) != sizeof(ack) ||
+	    imsg_get_id(&imsg) != 0 ||
+	    imsg_get_data(&imsg, &ack, sizeof(ack)) == -1 ||
+	    ack.control != FUGU_IMSG_ARTIFACT_TURN_BEGIN)
+		errx(2, "fugu-artifact sent malformed Turn begin ACK");
+	imsg_free(&imsg);
+
+	memset(&begin, 0, sizeof(begin));
+	begin.kind = ARTIFACT_BEGIN_FOREGROUND;
+	send_msg_id(ibuf, FUGU_IMSG_ARTIFACT_BEGIN, 1,
+	    &begin, sizeof(begin));
+	if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_STREAM, &imsg) == -1 ||
+	    imsg_get_len(&imsg) != sizeof(stream) ||
+	    imsg_get_id(&imsg) != 1 ||
+	    imsg_get_data(&imsg, &stream, sizeof(stream)) == -1 ||
+	    stream.status != ARTIFACT_STREAM_AVAILABLE ||
+	    !artifact_id_valid(stream.id) ||
+	    (fd = imsg_get_fd(&imsg)) == -1 ||
+	    (fdflags = fcntl(fd, F_GETFD)) == -1 ||
+	    fcntl(fd, F_SETFD, fdflags | FD_CLOEXEC) == -1 ||
+	    !artifact_producer_endpoint_valid(fd))
+		errx(2, "fugu-artifact sent malformed producer endpoint");
+	imsg_free(&imsg);
+	if (write(fd, probe, sizeof(probe) - 1) !=
+	    (ssize_t)(sizeof(probe) - 1))
+		errx(2, "write Artifact producer endpoint");
+	close(fd);
+
+	if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_TERMINAL, &imsg) == -1 ||
+	    imsg_get_len(&imsg) != sizeof(terminal) ||
+	    imsg_get_id(&imsg) != 1 ||
+	    imsg_get_data(&imsg, &terminal, sizeof(terminal)) == -1 ||
+	    terminal.status != ARTIFACT_STORE_OK ||
+	    terminal.retained_bytes != sizeof(probe) - 1 ||
+	    memcmp(terminal.id, stream.id, sizeof(stream.id)) != 0)
+		errx(2, "fugu-artifact sent malformed stream terminal");
+	imsg_free(&imsg);
+
+	send_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_ABORT, NULL, 0);
+	if (expect_msg(ibuf, FUGU_IMSG_ARTIFACT_TURN_ACK, &imsg) == -1 ||
+	    imsg_get_len(&imsg) != sizeof(ack) ||
+	    imsg_get_id(&imsg) != 0 ||
+	    imsg_get_data(&imsg, &ack, sizeof(ack)) == -1 ||
+	    ack.control != FUGU_IMSG_ARTIFACT_TURN_ABORT)
+		errx(2, "fugu-artifact sent malformed Turn abort ACK");
+	imsg_free(&imsg);
+}
+
+static void
+send_msg_fd_id(struct imsgbuf *ibuf, uint32_t type, uint32_t id, int fd,
+    const void *data, size_t len)
+{
+	if (imsg_compose(ibuf, type, id, -1, fd, data, len) == -1)
+		errx(2, "descriptor imsg_compose");
+	if (imsgbuf_flush(ibuf) == -1)
+		errx(2, "descriptor imsgbuf_flush");
+}
+
+static int
+send_artifact_stream_status(struct imsgbuf *ibuf, uint32_t id,
+    const char *artifact_id, int available)
+{
+	struct artifact_stream stream;
+	int p[2];
+
+	memset(&stream, 0, sizeof(stream));
+	stream.status = available ? ARTIFACT_STREAM_AVAILABLE :
+	    ARTIFACT_STREAM_UNAVAILABLE;
+	if (!available) {
+		send_msg_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_STREAM, id,
+		    &stream, sizeof(stream));
+		return (-1);
+	}
+	if (pipe2(p, O_CLOEXEC) == -1)
+		errx(2, "Artifact fixture pipe");
+	strlcpy(stream.id, artifact_id, sizeof(stream.id));
+	send_msg_fd_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_STREAM, id, p[1],
+	    &stream, sizeof(stream));
+	return (p[0]);
+}
+
+static int
+send_artifact_stream(struct imsgbuf *ibuf, uint32_t id)
+{
+	return (send_artifact_stream_status(ibuf, id, artifact_id_one, 1));
+}
+
+static void
+send_tool_req(struct imsgbuf *ibuf, uint32_t id, const char *name)
+{
+	struct tool_req req;
+
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.name, name, sizeof(req.name));
+	send_msg_id(ibuf, FUGU_IMSG_TOOL_REQ, id, &req, sizeof(req));
+}
+
+static void
+send_tool_call(struct imsgbuf *ibuf, uint32_t id, const char *name,
+    const char *arg)
+{
+	send_tool_req(ibuf, id, name);
+	if (arg != NULL)
+		send_msg_id(ibuf, FUGU_IMSG_TOOL_ARG, id, arg, strlen(arg));
+	send_msg_id(ibuf, FUGU_IMSG_TOOL_RUN, id, NULL, 0);
+}
+
+static int
+tool_turn_control(struct imsgbuf *ibuf, uint32_t control)
+{
+	struct artifact_turn_ack ack;
+	struct imsg imsg;
+	int ok;
+
+	send_msg(ibuf, control, NULL, 0);
+	if (expect_msg(ibuf, FUGU_IMSG_TOOL_TURN_ACK, &imsg) == -1)
+		return (-1);
+	ok = imsg_get_id(&imsg) == 0 &&
+	    imsg_get_len(&imsg) == sizeof(ack) &&
+	    imsg_get_data(&imsg, &ack, sizeof(ack)) == 0 &&
+	    ack.control == control;
+	imsg_free(&imsg);
+	return (ok ? 0 : -1);
+}
+
+static int
+expect_tool_success(struct imsgbuf *ibuf, uint32_t id, int artifact)
+{
+	struct tool_result result;
+	struct imsg imsg;
+	u_char *p;
+	size_t len;
+	int ok;
+
+	if (expect_msg(ibuf, FUGU_IMSG_TOOL_OUT, &imsg) == -1 ||
+	    imsg_get_id(&imsg) != id)
+		return (-1);
+	len = imsg_get_len(&imsg);
+	p = len == 0 ? NULL : malloc(len);
+	if (len != 0 && (p == NULL || imsg_get_data(&imsg, p, len) == -1)) {
+		free(p);
+		imsg_free(&imsg);
+		return (-1);
+	}
+	ok = p != NULL && memmem(p, len, "scope-ok", 8) != NULL;
+	free(p);
+	imsg_free(&imsg);
+	if (!ok || expect_msg(ibuf, FUGU_IMSG_TOOL_RESULT, &imsg) == -1)
+		return (-1);
+	if (imsg_get_id(&imsg) != id || imsg_get_len(&imsg) != sizeof(result) ||
+	    imsg_get_data(&imsg, &result, sizeof(result)) == -1) {
+		imsg_free(&imsg);
+		return (-1);
+	}
+	imsg_free(&imsg);
+	return (!result.is_error && !!result.artifact == !!artifact &&
+	    !result.spool_frozen ? 0 : -1);
+}
+
+/* A managed shell_output publishes its ownership frame before ordinary
+ * output.  want_adopted == -1 proves that no frame preceded the result. */
+static int
+expect_tool_artifact_result(struct imsgbuf *ibuf, uint32_t id,
+    int want_adopted, int want_detach, uint32_t job_id, const char *frozen_id,
+    const char *replacement_id, struct tool_result *result)
+{
+	static const char zero[FUGU_ARTIFACT_ID_BUFSZ];
+	struct artifact_spool_rotate rotate;
+	struct imsg imsg;
+	const char *want_frozen = frozen_id == NULL ? zero : frozen_id;
+	const char *want_replacement = replacement_id == NULL ? zero :
+	    replacement_id;
+	int ok;
+
+	if (want_adopted != -1) {
+		if (expect_msg(ibuf, FUGU_IMSG_TOOL_ARTIFACT_ROTATE, &imsg) == -1)
+			return (-1);
+		ok = imsg_get_id(&imsg) == id &&
+		    imsg_get_len(&imsg) == sizeof(rotate) &&
+		    imsg_get_data(&imsg, &rotate, sizeof(rotate)) == 0 &&
+		    rotate.job_id == job_id &&
+		    rotate.replacement_adopted == (uint32_t)want_adopted &&
+		    rotate.detach == (uint32_t)want_detach &&
+		    memcmp(rotate.frozen_id, want_frozen,
+		    sizeof(rotate.frozen_id)) == 0 &&
+		    memcmp(rotate.replacement_id, want_replacement,
+		    sizeof(rotate.replacement_id)) == 0;
+		imsg_free(&imsg);
+		if (!ok) {
+			warnx("wrong freeze frame: job=%u adopted=%u detach=%u "
+			    "frozen=%s replacement=%s", rotate.job_id,
+			    rotate.replacement_adopted, rotate.detach,
+			    rotate.frozen_id, rotate.replacement_id);
+			return (-1);
+		}
+	}
+	if (expect_msg(ibuf, FUGU_IMSG_TOOL_OUT, &imsg) == -1)
+		return (-1);
+	ok = imsg_get_id(&imsg) == id;
+	imsg_free(&imsg);
+	if (!ok || expect_msg(ibuf, FUGU_IMSG_TOOL_RESULT, &imsg) == -1)
+		return (-1);
+	memset(result, 0, sizeof(*result));
+	ok = imsg_get_id(&imsg) == id &&
+	    imsg_get_len(&imsg) == sizeof(*result) &&
+	    imsg_get_data(&imsg, result, sizeof(*result)) == 0;
+	imsg_free(&imsg);
+	return (ok ? 0 : -1);
+}
+
+static int
+send_artifact_cancel(struct imsgbuf *ibuf, uint32_t job_id, const char *id)
+{
+	struct artifact_job_cancel_ack ack;
+	struct artifact_record record;
+	struct imsg imsg;
+	int ok;
+
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, id, sizeof(record.id));
+	send_msg_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_CANCEL, job_id, &record,
+	    sizeof(record));
+	if (expect_msg(ibuf, FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK, &imsg) == -1)
+		return (-1);
+	ok = imsg_get_id(&imsg) == job_id &&
+	    imsg_get_len(&imsg) == sizeof(ack) &&
+	    imsg_get_data(&imsg, &ack, sizeof(ack)) == 0 &&
+	    ack.job_id == job_id && strcmp(ack.artifact_id, id) == 0;
+	imsg_free(&imsg);
+	return (ok ? 0 : -1);
+}
+
+static int
+test_tool_freeze_frames(struct imsgbuf *ibuf, pid_t worker_pid, int action)
+{
+	static const char zero[FUGU_ARTIFACT_ID_BUFSZ];
+	struct artifact_record record;
+	struct tool_result result;
+	char arg[64];
+	const char *command;
+	pid_t notifier = -1;
+	uint32_t bg_id = 101, poll_id = 102, job_id;
+	int old_fd = -1, replacement_fd = -1, status;
+	int old_available = action != TEST_TOOL_FREEZE_UNAVAILABLE;
+	int rc = -1;
+
+	command = action == TEST_TOOL_FREEZE_CANCEL ?
+	    "{\"command\":\"yes x\"}" :
+	    action == TEST_TOOL_FREEZE_SWAP ?
+	    "{\"command\":\"printf swap; sleep 30\"}" :
+	    "{\"command\":\"/usr/bin/true\"}";
+	if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_BEGIN) == -1)
+		goto done;
+	old_fd = send_artifact_stream_status(ibuf, bg_id, artifact_id_one,
+	    old_available);
+	send_tool_call(ibuf, bg_id, "shell_bg", command);
+	if (expect_tool_artifact_result(ibuf, bg_id, -1, 0, 0, NULL, NULL,
+	    &result) == -1 || result.is_error || result.spool_frozen ||
+	    !result.spool_adopted ||
+	    result.background_job_id <= 0) {
+		warnx("wrong shell_bg result: error=%d frozen=%d adopted=%d job=%d",
+		    result.is_error, result.spool_frozen, result.spool_adopted,
+		    result.background_job_id);
+		goto done;
+	}
+	job_id = (uint32_t)result.background_job_id;
+	if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_COMMIT) == -1)
+		goto done;
+	usleep(action == TEST_TOOL_FREEZE_CANCEL ? 700000 : 350000);
+	if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_BEGIN) == -1)
+		goto done;
+	replacement_fd = send_artifact_stream_status(ibuf, poll_id,
+	    artifact_id_two, 1);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%u}", job_id);
+	if (action == TEST_TOOL_FREEZE_CANCEL) {
+		notifier = fork();
+		if (notifier == -1)
+			goto done;
+		if (notifier == 0) {
+			usleep(200000);
+			(void)kill(worker_pid, SIGUSR1);
+			_exit(0);
+		}
+	}
+	send_tool_call(ibuf, poll_id, "shell_output", arg);
+	if (action == TEST_TOOL_FREEZE_SWAP) {
+		if (expect_tool_artifact_result(ibuf, poll_id, 1, 1, job_id,
+		    artifact_id_one, artifact_id_two, &result) == -1 ||
+		    result.is_error || !result.spool_frozen ||
+		    !result.spool_adopted ||
+		    result.background_job_id != (int)job_id) {
+			warnx("wrong swap result: error=%d frozen=%d adopted=%d job=%d",
+			    result.is_error, result.spool_frozen,
+			    result.spool_adopted, result.background_job_id);
+			goto done;
+		}
+	} else if (action == TEST_TOOL_FREEZE_TERMINAL) {
+		if (expect_tool_artifact_result(ibuf, poll_id, 0, 0, job_id,
+		    artifact_id_one, NULL, &result) == -1 || result.is_error ||
+		    !result.spool_frozen || result.spool_adopted ||
+		    result.background_job_id != (int)job_id ||
+		    strcmp(result.artifact_id, artifact_id_one) != 0) {
+			warnx("wrong available terminal result");
+			goto done;
+		}
+	} else if (action == TEST_TOOL_FREEZE_UNAVAILABLE) {
+		if (expect_tool_artifact_result(ibuf, poll_id, 0, 0, job_id,
+		    NULL, NULL, &result) == -1 || result.is_error ||
+		    !result.spool_frozen || result.spool_adopted ||
+		    result.background_job_id != (int)job_id ||
+		    memcmp(result.artifact_id, zero,
+		    sizeof(result.artifact_id)) != 0) {
+			warnx("wrong unavailable terminal result");
+			goto done;
+		}
+	} else {
+		if (expect_tool_artifact_result(ibuf, poll_id, -1, 0, job_id,
+		    NULL, NULL, &result) == -1 || result.is_error ||
+		    result.spool_frozen || result.spool_adopted ||
+		    result.background_job_id != (int)job_id) {
+			warnx("wrong cancelled pre-freeze result");
+			goto done;
+		}
+	}
+	if (notifier != -1) {
+		if (waitpid(notifier, &status, 0) == -1 || !WIFEXITED(status) ||
+		    WEXITSTATUS(status) != 0)
+			goto done;
+		notifier = -1;
+	}
+	if (action == TEST_TOOL_FREEZE_TERMINAL) {
+		memset(&record, 0, sizeof(record));
+		strlcpy(record.id, artifact_id_one, sizeof(record.id));
+		send_msg_id(ibuf, FUGU_IMSG_TOOL_ARTIFACT_RELEASE, job_id,
+		    &record, sizeof(record));
+	}
+	if (tool_turn_control(ibuf, FUGU_IMSG_TOOL_TURN_ABORT) == -1)
+		goto done;
+	if (action == TEST_TOOL_FREEZE_SWAP) {
+		if (send_artifact_cancel(ibuf, job_id, artifact_id_two) == -1)
+			goto done;
+	} else if (action == TEST_TOOL_FREEZE_CANCEL) {
+		if (send_artifact_cancel(ibuf, job_id, artifact_id_one) == -1)
+			goto done;
+	}
+	rc = 0;
+done:
+	if (notifier > 0) {
+		(void)kill(notifier, SIGTERM);
+		(void)waitpid(notifier, NULL, 0);
+	}
+	if (old_fd != -1)
+		close(old_fd);
+	if (replacement_fd != -1)
+		close(replacement_fd);
+	return (rc);
+}
+
+static int
+worker_rejected(pid_t pid)
+{
+	int i, status;
+	pid_t rc;
+
+	for (i = 0; i < 200; i++) {
+		rc = waitpid(pid, &status, WNOHANG);
+		if (rc == pid)
+			return (WIFEXITED(status) && WEXITSTATUS(status) == 1);
+		if (rc == -1)
+			return (0);
+		usleep(10000);
+	}
+	(void)kill(pid, SIGKILL);
+	(void)waitpid(pid, &status, 0);
+	return (0);
+}
+
 static enum fugu_role
 role_of(const char *s)
 {
@@ -113,6 +539,8 @@ role_of(const char *s)
 		return (ROLE_WEB);
 	if (strcmp(s, "fugu-tool") == 0)
 		return (ROLE_TOOL);
+	if (strcmp(s, "fugu-artifact") == 0)
+		return (ROLE_ARTIFACT);
 	if (strcmp(s, "fugu-editor") == 0)
 		return (ROLE_EDIT);
 	errx(1, "unknown role %s", s);
@@ -143,6 +571,22 @@ action_of(const char *s)
 		return (TEST_BAD_PING_ID);
 	if (strcmp(s, "claude_models") == 0)
 		return (TEST_CLAUDE_MODELS);
+	if (strcmp(s, "tool_descriptor_read") == 0)
+		return (TEST_TOOL_DESCRIPTOR_READ);
+	if (strcmp(s, "tool_shell_no_stream") == 0)
+		return (TEST_TOOL_SHELL_NO_STREAM);
+	if (strcmp(s, "tool_reordered_stream") == 0)
+		return (TEST_TOOL_REORDERED_STREAM);
+	if (strcmp(s, "tool_shell") == 0)
+		return (TEST_TOOL_SHELL);
+	if (strcmp(s, "tool_freeze_swap") == 0)
+		return (TEST_TOOL_FREEZE_SWAP);
+	if (strcmp(s, "tool_freeze_terminal") == 0)
+		return (TEST_TOOL_FREEZE_TERMINAL);
+	if (strcmp(s, "tool_freeze_unavailable") == 0)
+		return (TEST_TOOL_FREEZE_UNAVAILABLE);
+	if (strcmp(s, "tool_freeze_cancel") == 0)
+		return (TEST_TOOL_FREEZE_CANCEL);
 	if (strcmp(s, "none") == 0)
 		return (0);
 	errx(1, "unknown action %s", s);
@@ -191,6 +635,7 @@ main(int argc, char *argv[])
 		errx(2, "spawn %s", rolename);
 	if (imsgbuf_init(&ibuf, kid.fd) == -1)
 		errx(2, "imsgbuf_init");
+	imsgbuf_allow_fdpass(&ibuf);
 	if (action == TEST_BAD_HELLO || action == TEST_BAD_HELLO_ID) {
 		if (action == TEST_BAD_HELLO)
 			send_msg(&ibuf, FUGU_IMSG_HELLO, &byte, sizeof(byte));
@@ -246,6 +691,7 @@ main(int argc, char *argv[])
 		memset(&h, 0, sizeof(h));
 		h.allow_write = !nowrite;
 		h.allow_subprocess_net = 0;
+		h.max_bg_jobs = 4;
 		send_msg(&ibuf, FUGU_IMSG_HELLO, &h, sizeof(h));
 	} else if (role == ROLE_WEB) {
 		struct hello_web	 h;
@@ -253,6 +699,35 @@ main(int argc, char *argv[])
 		memset(&h, 0, sizeof(h));
 		strlcpy(h.search_provider, "kagi", sizeof(h.search_provider));
 		send_msg(&ibuf, FUGU_IMSG_HELLO, &h, sizeof(h));
+	} else if (role == ROLE_ARTIFACT) {
+		struct hello_artifact	 h;
+		int			 fdflags, lock_fd;
+
+		memset(&h, 0, sizeof(h));
+		h.fresh = 1;
+		if (snprintf(h.session, sizeof(h.session), "sandbox-%ld",
+		    (long)getpid()) < 0 ||
+		    getcwd(h.root, sizeof(h.root)) == NULL ||
+		    strlcat(h.root, "/artifacts", sizeof(h.root)) >=
+		    sizeof(h.root))
+			errx(2, "Artifact sandbox path");
+		send_msg(&ibuf, FUGU_IMSG_HELLO, &h, sizeof(h));
+
+		/* The lifetime lock is the Artifact Role's exact pre-READY
+		 * descriptor exchange.  Consume and validate it before the common
+		 * worker READY message, just as the Coordinator does. */
+		if (expect_msg(&ibuf, FUGU_IMSG_ARTIFACT_LIFETIME_LOCK,
+		    &imsg) == -1)
+			errx(2, "fugu-artifact omitted lifetime lock");
+		lock_fd = imsg_get_fd(&imsg);
+		if (imsg_get_len(&imsg) != 0 || imsg_get_id(&imsg) != 0 ||
+		    lock_fd == -1 ||
+		    (fdflags = fcntl(lock_fd, F_GETFD)) == -1 ||
+		    fcntl(lock_fd, F_SETFD, fdflags | FD_CLOEXEC) == -1 ||
+		    !artifact_lifetime_lock_valid(lock_fd, h.root, h.session))
+			errx(2, "fugu-artifact sent malformed lifetime lock");
+		close(lock_fd);
+		imsg_free(&imsg);
 	} else {
 		send_msg(&ibuf, FUGU_IMSG_HELLO, NULL, 0);
 	}
@@ -261,7 +736,80 @@ main(int argc, char *argv[])
 	if (imsg_get_len(&imsg) != 0 || imsg_get_id(&imsg) != 0)
 		errx(2, "%s sent malformed READY", rolename);
 	imsg_free(&imsg);
+	if (role == ROLE_ARTIFACT) {
+		/* Mirror the Coordinator's explicit startup phase boundary before
+		 * exercising any steady-state Turn or selftest request. */
+		send_msg(&ibuf, FUGU_IMSG_ARTIFACT_RECONCILE_END, NULL, 0);
+		if (expect_msg(&ibuf, FUGU_IMSG_ARTIFACT_RECONCILE_ACK,
+		    &imsg) == -1 || imsg_get_len(&imsg) != 0 ||
+		    imsg_get_id(&imsg) != 0 || imsg_get_fd(&imsg) != -1)
+			errx(2, "fugu-artifact did not finish reconciliation");
+		imsg_free(&imsg);
+	}
 
+	if (role == ROLE_TOOL && (action == TEST_TOOL_FREEZE_SWAP ||
+	    action == TEST_TOOL_FREEZE_TERMINAL ||
+	    action == TEST_TOOL_FREEZE_UNAVAILABLE ||
+	    action == TEST_TOOL_FREEZE_CANCEL)) {
+		int ok = test_tool_freeze_frames(&ibuf, kid.pid, action) == 0;
+
+		if (!ok) {
+			(void)kill(kid.pid, SIGKILL);
+			(void)waitpid(kid.pid, &status, 0);
+			return (1);
+		}
+		send_msg(&ibuf, FUGU_IMSG_SHUTDOWN, NULL, 0);
+		if (waitpid(kid.pid, &status, 0) == -1)
+			errx(2, "waitpid Tool freeze frame");
+		return (WIFEXITED(status) && WEXITSTATUS(status) == 0 ? 0 : 1);
+	}
+
+	if (role == ROLE_TOOL && (action == TEST_TOOL_DESCRIPTOR_READ ||
+	    action == TEST_TOOL_SHELL_NO_STREAM ||
+	    action == TEST_TOOL_REORDERED_STREAM ||
+	    action == TEST_TOOL_SHELL)) {
+		static const char shell_arg[] =
+		    "{\"command\":\"printf scope-ok\"}";
+		uint32_t id = 77;
+		int artifact_fd = -1, ok;
+
+		if (action == TEST_TOOL_DESCRIPTOR_READ) {
+			artifact_fd = send_artifact_stream(&ibuf, id);
+			send_tool_req(&ibuf, id, "read");
+		} else if (action == TEST_TOOL_SHELL_NO_STREAM) {
+			send_tool_req(&ibuf, 0, "shell");
+		} else if (action == TEST_TOOL_REORDERED_STREAM) {
+			send_tool_req(&ibuf, 0, "read");
+			artifact_fd = send_artifact_stream(&ibuf, id);
+		} else {
+			if (readonly)
+				id = 0;
+			else
+				artifact_fd = send_artifact_stream(&ibuf, id);
+			send_tool_req(&ibuf, id, "shell");
+			send_msg_id(&ibuf, FUGU_IMSG_TOOL_ARG, id, shell_arg,
+			    sizeof(shell_arg) - 1);
+			send_msg_id(&ibuf, FUGU_IMSG_TOOL_RUN, id, NULL, 0);
+			ok = expect_tool_success(&ibuf, id, !readonly) == 0;
+			if (artifact_fd != -1)
+				close(artifact_fd);
+			if (!ok) {
+				(void)kill(kid.pid, SIGKILL);
+				(void)waitpid(kid.pid, &status, 0);
+				return (1);
+			}
+			send_msg(&ibuf, FUGU_IMSG_SHUTDOWN, NULL, 0);
+			if (waitpid(kid.pid, &status, 0) == -1)
+				errx(2, "waitpid Tool scope success");
+			return (WIFEXITED(status) && WEXITSTATUS(status) == 0 ?
+			    0 : 1);
+		}
+		ok = worker_rejected(kid.pid);
+		if (artifact_fd != -1)
+			close(artifact_fd);
+		return (ok ? 0 : 1);
+	}
+
 	if (action == TEST_CLAUDE_MODELS) {
 		struct model_ent me;
 		u_char p[sizeof(me) + 1 + FUGU_MODEL_ID_MAX];
@@ -306,7 +854,9 @@ main(int argc, char *argv[])
 	}
 
 	if (action == 0) {
-		/* handshake-only: verify liveness then shut down cleanly */
+		/* Verify liveness and Artifact's typed descriptor exchange. */
+		if (role == ROLE_ARTIFACT)
+			exercise_artifact_stream(&ibuf);
 		send_msg(&ibuf, FUGU_IMSG_PING, NULL, 0);
 		if (expect_msg(&ibuf, FUGU_IMSG_PONG, &imsg) == -1)
 			errx(2, "%s did not pong", rolename);
blob - dfeb858e0b2c2973fb5f97752d25b6efcb7d9212
blob + 1df4955f3793f707c20254cf3f3a0fa5f047343a
--- regress/sandbox/run.sh
+++ regress/sandbox/run.sh
@@ -53,7 +53,7 @@ mkdir -p "$lx" "$tree"
 trap 'rm -rf "$dir"' EXIT INT TERM
 
 # Locate and stage the real worker binaries.
-for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do
+for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do
 	bin=$root/src/$w/obj/$w
 	[ -x "$bin" ] || bin=$root/src/$w/$w
 	if [ ! -x "$bin" ]; then
@@ -89,8 +89,27 @@ run_case fugu-api  none ok -
 run_case fugu-api  claude_models ok -
 run_case fugu-web  none ok -
 run_case fugu-tool none ok -
+run_case fugu-artifact none ok -
 run_case fugu-editor none ok -
 
+# Persistent Lead Artifact scope is an exact protocol property: scoped calls
+# require one descriptor, unscoped calls forbid it, and a descriptor cannot be
+# injected after a descriptor-free request has begun.  The -r executor remains
+# descriptor-free and can run its ordinary inspection shell unchanged.
+run_case fugu-tool   tool_descriptor_read  reject -
+run_case fugu-tool   tool_shell_no_stream  reject -
+run_case fugu-tool   tool_reordered_stream reject -
+run_case fugu-tool   tool_shell            ok -
+run_case fugu-tool-r tool_shell            ok -
+
+# shell_output's out-of-band frame is the ownership proof: a running swap
+# names both ids, terminal freezes name no replacement (including a zero-id
+# unavailable old spool), and an interrupted pre-freeze call emits no frame.
+run_case fugu-tool tool_freeze_swap        ok -
+run_case fugu-tool tool_freeze_terminal    ok -
+run_case fugu-tool tool_freeze_unavailable ok -
+run_case fugu-tool tool_freeze_cancel      ok -
+
 # I10/I13: startup and liveness frames are exact typed exchanges.  The TTY
 # and editor historically accepted malformed HELLO payloads; the common
 # worker runtime must also reject nonzero control correlation IDs and malformed
@@ -109,6 +128,15 @@ run_case fugu-api-claude open_etc dead open
 run_case fugu-web exec     dead execve
 run_case fugu-web open_etc dead open
 
+# The Artifact Role can use only its private managed root: it cannot execute,
+# reach the network, read configuration, access the tty, or write the project.
+# The first two are pledge kills; the path probes are unveil-shaped ENOENTs.
+run_case fugu-artifact exec          dead  execve
+run_case fugu-artifact connect       dead  socket
+run_case fugu-artifact open_etc      errno -
+run_case fugu-artifact open_tty      errno -
+run_case fugu-artifact write_intree  errno -
+
 # I7: the blast room cannot reach the tty.
 run_case fugu-web open_tty dead open
 
blob - 8419f1e7f89a5ed2419fc81ac4e8e053a50195c3
blob + c9cb30793f43409f0341a3a161f935ea4d158cda
--- regress/tools/Makefile
+++ regress/tools/Makefile
@@ -4,6 +4,7 @@ SRCS=	tools_test.c tools.c job.c supervisor.c artifact
 NOMAN=	yes
 
 CFLAGS+=	-I${.CURDIR}/../../src/fugu -I${.CURDIR}/../../src/fugu-tool
+CFLAGS+=	-DJOB_TESTING
 .PATH:		${.CURDIR}/../../src/fugu-tool ${.CURDIR}/../../src/fugu
 
 REGRESS_TARGETS=	run-tools
blob - e3cb883d0b5c9c2820c1174582e015b39cb195ee
blob + bf5cedd91dd6f600a031196206f2f8eb9f4312eb
--- regress/tools/tools_test.c
+++ regress/tools/tools_test.c
@@ -36,6 +36,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <syslog.h>
+#include <time.h>
 #include <unistd.h>
 
 #include "log.h"
@@ -48,7 +49,19 @@
 static struct tool_ctx	*ctx;
 static struct buf	 out;
 static int		 err_flag;
+static uint32_t		 managed_correlation = 100;
 
+static const char artifact_ids[][ARTIFACT_ID_HEX_LEN + 1] = {
+	"10000000000000000000000000000001",
+	"20000000000000000000000000000002",
+	"30000000000000000000000000000003",
+	"40000000000000000000000000000004",
+	"50000000000000000000000000000005",
+	"60000000000000000000000000000006",
+	"70000000000000000000000000000007",
+	"80000000000000000000000000000008"
+};
+
 static void
 run(const char *name, const char *input)
 {
@@ -58,6 +71,93 @@ run(const char *name, const char *input)
 	tool_exec(ctx, name, input, strlen(input), &out, &err_flag);
 }
 
+static void
+managed_run(const char *name, const char *input, const char *id, int fd,
+    int available)
+{
+	ctx->artifact_mode = 1;
+	ctx->artifact_available = available;
+	ctx->artifact_fd = fd;
+	ctx->artifact_id = available ? id : NULL;
+	ctx->artifact_correlation = managed_correlation++;
+	run(name, input);
+}
+
+static void
+cancel_active_signal(int sig)
+{
+	(void)sig;
+	tool_cancel_active();
+}
+
+static void
+nonblock_fd(int fd)
+{
+	int flags = fcntl(fd, F_GETFL);
+
+	if (flags == -1 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1)
+		err(1, "nonblocking fixture fd");
+}
+
+static uint64_t
+drain_bytes(int fd)
+{
+	u_char bytes[64 * 1024];
+	uint64_t total = 0;
+	ssize_t n;
+
+	for (;;) {
+		n = read(fd, bytes, sizeof(bytes));
+		if (n > 0) {
+			total += (uint64_t)n;
+			continue;
+		}
+		if (n == -1 && errno == EINTR)
+			continue;
+		break;
+	}
+	return (total);
+}
+
+static void
+fill_pipe(int fd)
+{
+	u_char bytes[4096];
+	ssize_t n;
+
+	memset(bytes, 0xa5, sizeof(bytes));
+	nonblock_fd(fd);
+	for (;;) {
+		n = write(fd, bytes, sizeof(bytes));
+		if (n > 0)
+			continue;
+		if (n == -1 && errno == EINTR)
+			continue;
+		if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK))
+			return;
+		err(1, "fill fixture pipe");
+	}
+}
+
+static long
+elapsed_ms(const struct timespec *start, const struct timespec *end)
+{
+	return ((long)(end->tv_sec - start->tv_sec) * 1000L +
+	    (end->tv_nsec - start->tv_nsec) / 1000000L);
+}
+
+static void
+managed_clear(void)
+{
+	if (ctx->artifact_fd != -1)
+		close(ctx->artifact_fd);
+	ctx->artifact_fd = -1;
+	ctx->artifact_mode = 0;
+	ctx->artifact_available = 0;
+	ctx->artifact_id = NULL;
+	ctx->artifact_correlation = 0;
+}
+
 /* memmem so NUL-bearing results are searched length-aware */
 static int
 outhas(const char *s)
@@ -170,14 +270,824 @@ make_long_line(const char *path, size_t target)
 	close(fd);
 }
 
+static void
+test_managed_artifacts(void)
+{
+	struct job_test_state cap_state;
+	struct timespec start, end;
+	u_char sink[16 * 1024];
+	char arg[64];
+	int p1[2], p2[2], job_id, i, duplicate, nullfd, status;
+	pid_t notifier;
+	uint64_t frozen_expected, snapshot, spooled;
+
+	job_set_cap(ctx->jobs, 4);
+
+	/* Foreground capture observes no more than the one byte needed to prove
+	 * a cap crossing.  Exact-cap EOF is success; crossing is exactly +1. */
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell", "{\"command\":\"/usr/bin/perl -e "
+	    "'print q(x) x 16777216'\"}", artifact_ids[0], nullfd, 1);
+	CHECK(!err_flag && !ctx->artifact_cap_reached &&
+	    ctx->artifact_termination == ARTIFACT_TERM_EXIT &&
+	    ctx->artifact_observed == ARTIFACT_RAW_MAX &&
+	    ctx->artifact_raw_len == ARTIFACT_RAW_MAX);
+	managed_clear();
+
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell", "{\"command\":\"/usr/bin/perl -e "
+	    "'print q(x) x 17825792'\"}", artifact_ids[0], nullfd, 1);
+	CHECK(err_flag && ctx->artifact_cap_reached &&
+	    ctx->artifact_termination == ARTIFACT_TERM_CAP &&
+	    ctx->artifact_observed == ARTIFACT_RAW_MAX + 1ULL &&
+	    ctx->artifact_raw_len == ARTIFACT_RAW_MAX);
+	managed_clear();
+
+	/* A replacement created in the spawning Turn remains Turn-owned. */
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0);
+	nonblock_fd(p1[0]);
+	managed_run("shell_bg",
+	    "{\"command\":\"printf owned-marker; sleep 30\"}",
+	    artifact_ids[0], p1[1], 1);
+	job_id = ctx->artifact_job_id;
+	CHECK(!err_flag && ctx->artifact_spool_adopted && job_id > 0);
+	for (i = 0; i < 20; i++) {
+		job_pump(ctx->jobs);
+		(void)drain_bytes(p1[0]);
+		usleep(10000);
+	}
+	CHECK(pipe(p2) == 0);
+	nonblock_fd(p2[0]);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	managed_run("shell_output", arg, artifact_ids[1], p2[1], 1);
+	CHECK(ctx->artifact_job_id == job_id && ctx->artifact_spool_adopted &&
+	    !ctx->artifact_spool_detach && ctx->artifact_result_available &&
+	    strcmp(ctx->artifact_result_id, artifact_ids[0]) == 0);
+	CHECK(memmem(ctx->artifact_capture.full.data,
+	    ctx->artifact_capture.full.len, "owned-marker", 12) != NULL);
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[0]) == 0);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+	close(p1[0]);
+	close(p2[0]);
+	run("shell_output", arg);
+	CHECK(err_flag && outhas("no such job"));
+
+	/* A committed job's later poll replacement detaches before abort. */
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0);
+	nonblock_fd(p1[0]);
+	managed_run("shell_bg", "{\"command\":\"printf durable; sleep 30\"}",
+	    artifact_ids[2], p1[1], 1);
+	job_id = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p2) == 0);
+	nonblock_fd(p2[0]);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	managed_run("shell_output", arg, artifact_ids[3], p2[1], 1);
+	CHECK(ctx->artifact_spool_adopted && ctx->artifact_spool_detach &&
+	    ctx->artifact_job_id == job_id);
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[2]) == 0);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+	close(p1[0]);
+	close(p2[0]);
+	run("shell_output", arg);
+	CHECK(!err_flag && !outhas("no such job"));
+	run("shell_kill", arg);
+	CHECK(!err_flag);
+
+	/* A producer that completed with no bytes is reaped and EOF-closed in the
+	 * same poll.  It must not report running or adopt an endless replacement. */
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"/usr/bin/true\"}",
+	    artifact_ids[6], nullfd, 1);
+	job_id = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	usleep(300000);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	managed_run("shell_output", arg, artifact_ids[7], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_captured &&
+	    ctx->artifact_termination == ARTIFACT_TERM_EXIT &&
+	    ctx->artifact_observed == 0 && ctx->artifact_raw_len == 0 &&
+	    ctx->artifact_result_available && !ctx->artifact_spool_adopted);
+	managed_clear();
+	run("shell_output", arg);
+	CHECK(err_flag && outhas("no such job"));
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[6]) == 0);
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[6]) == -1);
+	CHECK(job_artifact_cancel(ctx->jobs, job_id, artifact_ids[6]) == -1);
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[7]) == -1);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	/* shell_output freezes one FIONREAD snapshot.  A blocked fast producer
+	 * cannot refill the pipe into the segment while that snapshot is drained. */
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"yes snapshot\"}",
+	    artifact_ids[4], nullfd, 1);
+	job_id = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	for (i = 0; i < 200 && job_test_output_bytes(ctx->jobs, job_id) == 0;
+	    i++)
+		usleep(5000);
+	usleep(50000);
+	snapshot = job_test_output_bytes(ctx->jobs, job_id);
+	CHECK(snapshot > 0 && snapshot <= job_test_pump_per_job_limit());
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	managed_run("shell_output", arg, artifact_ids[5], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_observed == snapshot &&
+	    ctx->artifact_raw_len == snapshot && ctx->artifact_spool_adopted &&
+	    ctx->artifact_spool_detach);
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[4]) == 0);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+	run("shell_kill", arg);
+	CHECK(!err_flag);
+
+	/* Reservation-unavailable capture stays inline while safe and kills the
+	 * producer conspicuously exactly when no terminal form can fit. */
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	managed_run("shell_bg", "{\"command\":\"printf inline; sleep 30\"}",
+	    artifact_ids[4], -1, 0);
+	job_id = ctx->artifact_job_id;
+	for (i = 0; i < 10; i++)
+		job_pump(ctx->jobs);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	managed_run("shell_output", arg, artifact_ids[5], -1, 0);
+	CHECK(!err_flag && !ctx->artifact_result_available &&
+	    ctx->artifact_termination == ARTIFACT_TERM_RUNNING);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	managed_run("shell_bg", "{\"command\":\"yes unavailable\"}",
+	    artifact_ids[4], -1, 0);
+	job_id = ctx->artifact_job_id;
+	for (i = 0; i < 200 &&
+	    !job_test_storage_unavailable(ctx->jobs, job_id); i++) {
+		job_pump(ctx->jobs);
+		usleep(5000);
+	}
+	CHECK(job_test_storage_unavailable(ctx->jobs, job_id));
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	managed_run("shell_output", arg, artifact_ids[5], -1, 0);
+	CHECK(err_flag &&
+	    ctx->artifact_termination == ARTIFACT_TERM_STORAGE_UNAVAILABLE &&
+	    ctx->artifact_raw_len <= ARTIFACT_RESULT_MAX);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+
+	/* Exact-cap bytes survive an injected EAGAIN. Once they flush, the next
+	 * producer byte records the crossing and the retained 16 MiB stays exact. */
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0);
+	nonblock_fd(p1[0]);
+	managed_run("shell_bg", "{\"command\":\"perl -e "
+	    "'print q(x) x 17825792'\"}",
+	    artifact_ids[6], p1[1], 1);
+	job_id = ctx->artifact_job_id;
+	job_test_backpressure_at_boundary();
+	spooled = 0;
+	clock_gettime(CLOCK_MONOTONIC, &start);
+	for (;;) {
+		job_pump(ctx->jobs);
+		spooled += drain_bytes(p1[0]);
+		if (job_test_boundary_pending(ctx->jobs, job_id))
+			break;
+		clock_gettime(CLOCK_MONOTONIC, &end);
+		if (elapsed_ms(&start, &end) >= 10000)
+			break;
+		if (job_test_last_pump_bytes(ctx->jobs, job_id) == 0)
+			usleep(1000);
+	}
+	if (!job_test_boundary_pending(ctx->jobs, job_id) &&
+	    job_test_state(ctx->jobs, job_id, &cap_state) == 0)
+		fprintf(stderr, "cap witness: observed=%llu retained=%llu "
+		    "pending=%zu cap=%d exited=%d producer=%d artifact=%d "
+		    "storage_error=%d unavailable=%d\n",
+		    (unsigned long long)cap_state.observed,
+		    (unsigned long long)cap_state.retained,
+		    cap_state.pending_bytes, cap_state.cap_reached,
+		    cap_state.exited, cap_state.producer_open,
+		    cap_state.artifact_open, cap_state.storage_error,
+		    cap_state.storage_unavailable);
+	CHECK(job_test_boundary_pending(ctx->jobs, job_id));
+	for (i = 0; i < 10; i++) {
+		spooled += drain_bytes(p1[0]);
+		job_pump(ctx->jobs);
+	}
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	managed_run("shell_output", arg, artifact_ids[7], -1, 0);
+	spooled += drain_bytes(p1[0]);
+	CHECK(err_flag && ctx->artifact_cap_reached &&
+	    ctx->artifact_termination == ARTIFACT_TERM_CAP &&
+	    ctx->artifact_observed == ARTIFACT_RAW_MAX + 1ULL &&
+	    ctx->artifact_raw_len == ARTIFACT_RAW_MAX &&
+	    spooled == ARTIFACT_RAW_MAX);
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[6]) == 0);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+	close(p1[0]);
+
+	/* Backpressure has no arbitrary settlement deadline.  Cancelling a poll
+	 * leaves a committed job and its old spool intact, so a later Turn can
+	 * drain and freeze the same bytes without loss. */
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p1) == 0);
+	nonblock_fd(p1[0]);
+	duplicate = dup(p1[1]);
+	CHECK(duplicate != -1);
+	managed_run("shell_bg", "{\"command\":\"yes blocked\"}",
+	    artifact_ids[0], p1[1], 1);
+	job_id = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	fill_pipe(duplicate);
+	close(duplicate);
+	for (i = 0; i < 200 &&
+	    job_test_pending_bytes(ctx->jobs, job_id) == 0; i++) {
+		job_pump(ctx->jobs);
+		usleep(5000);
+	}
+	CHECK(job_test_pending_bytes(ctx->jobs, job_id) > 0);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", job_id);
+	notifier = fork();
+	if (notifier == -1)
+		err(1, "fork background cancel notifier");
+	if (notifier == 0) {
+		usleep(150000);
+		(void)kill(getppid(), SIGUSR1);
+		_exit(0);
+	}
+	clock_gettime(CLOCK_MONOTONIC, &start);
+	managed_run("shell_output", arg, artifact_ids[1], nullfd, 1);
+	clock_gettime(CLOCK_MONOTONIC, &end);
+	(void)waitpid(notifier, &status, 0);
+	CHECK(!err_flag && !ctx->artifact_captured &&
+	    !ctx->artifact_spool_adopted && elapsed_ms(&start, &end) < 1500);
+	CHECK(job_test_state(ctx->jobs, job_id, &cap_state) == 0 &&
+	    !cap_state.storage_error && !cap_state.exited &&
+	    cap_state.producer_open && cap_state.artifact_open &&
+	    cap_state.pending_bytes > 0);
+	frozen_expected = cap_state.observed;
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+	notifier = fork();
+	if (notifier == -1)
+		err(1, "fork Artifact drain helper");
+	if (notifier == 0) {
+		ssize_t n;
+
+		close(p1[1]);
+		for (;;) {
+			n = read(p1[0], sink, sizeof(sink));
+			if (n > 0 || (n == -1 && errno == EINTR))
+				continue;
+			if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
+				usleep(1000);
+				continue;
+			}
+			_exit(n == 0 ? 0 : 2);
+		}
+	}
+	close(p1[0]);
+	snapshot = job_test_output_bytes(ctx->jobs, job_id);
+	CHECK(snapshot > 0 && snapshot <= job_test_pump_per_job_limit());
+	frozen_expected += snapshot;
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[1], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_captured &&
+	    ctx->artifact_spool_adopted && ctx->artifact_spool_detach &&
+	    ctx->artifact_observed == frozen_expected &&
+	    ctx->artifact_result_available &&
+	    strcmp(ctx->artifact_result_id, artifact_ids[0]) == 0);
+	(void)waitpid(notifier, &status, 0);
+	CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0);
+	CHECK(job_artifact_release(ctx->jobs, job_id, artifact_ids[0]) == 0);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+	run("shell_kill", arg);
+	CHECK(!err_flag);
+
+	/* Foreground writes share the execution deadline and SIGUSR1 cancels only
+	 * the active group; the persistent executor has no poisoned next-call latch. */
+	CHECK(pipe(p1) == 0);
+	fill_pipe(p1[1]);
+	ctx->shell_timeout_ms = 300;
+	clock_gettime(CLOCK_MONOTONIC, &start);
+	managed_run("shell", "{\"command\":\"printf x; sleep 30\"}",
+	    artifact_ids[2], p1[1], 1);
+	clock_gettime(CLOCK_MONOTONIC, &end);
+	CHECK(err_flag && ctx->artifact_termination == ARTIFACT_TERM_TIMEOUT &&
+	    elapsed_ms(&start, &end) < 1500);
+	managed_clear();
+	close(p1[0]);
+
+	CHECK(pipe(p1) == 0);
+	fill_pipe(p1[1]);
+	ctx->shell_timeout_ms = 5000;
+	notifier = fork();
+	if (notifier == -1)
+		err(1, "fork cancel notifier");
+	if (notifier == 0) {
+		usleep(150000);
+		(void)kill(getppid(), SIGUSR1);
+		_exit(0);
+	}
+	clock_gettime(CLOCK_MONOTONIC, &start);
+	managed_run("shell", "{\"command\":\"printf x; sleep 30\"}",
+	    artifact_ids[3], p1[1], 1);
+	clock_gettime(CLOCK_MONOTONIC, &end);
+	(void)waitpid(notifier, &status, 0);
+	CHECK(elapsed_ms(&start, &end) < 1500);
+	managed_clear();
+	close(p1[0]);
+	ctx->shell_timeout_ms = 0;
+	run("shell", "{\"command\":\"printf next-tool-ok\"}");
+	CHECK(!err_flag && outhas("next-tool-ok"));
+}
+
+static void
+test_bounded_job_retirement(void)
+{
+	struct tool_ctx *saved = ctx, *bounded;
+	struct artifact_spool_drop drop, bad_drop;
+	struct job_test_state state;
+	char arg[64], zero_id[FUGU_ARTIFACT_ID_BUFSZ] = { 0 };
+	int p[2], old_job, i, nullfd, settled;
+	uint32_t old_correlation, correlation;
+	uint64_t spooled;
+	ssize_t n;
+
+	bounded = tool_ctx_new(1, 0);
+	ctx = bounded;
+	job_set_cap(ctx->jobs, 4);
+	job_test_set_record_limit(ctx->jobs, 1);
+	job_test_set_tombstone_limit(ctx->jobs, 4);
+
+	/* The record bound is independent of the running-job cap.  Admitting a
+	 * second job retires the completed, unpolled first job and publishes its
+	 * exact current-spool key exactly once. */
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p) == 0);
+	nonblock_fd(p[0]);
+	managed_run("shell_bg", "{\"command\":\"printf retired-by-bound\"}",
+	    artifact_ids[0], p[1], 1);
+	old_job = ctx->artifact_job_id;
+	old_correlation = ctx->artifact_correlation;
+	CHECK(!err_flag && old_job > 0 && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	spooled = 0;
+	settled = 0;
+	for (i = 0; i < 200; i++) {
+		job_pump(ctx->jobs);
+		spooled += drain_bytes(p[0]);
+		if (job_test_state(ctx->jobs, old_job, &state) == 0 &&
+		    state.exited && !state.producer_open && !state.artifact_open &&
+		    state.pending_bytes == 0) {
+			settled = 1;
+			break;
+		}
+		usleep(5000);
+	}
+	CHECK(settled && spooled == strlen("retired-by-bound"));
+	n = read(p[0], &i, 1);
+	CHECK(n == 0);
+	close(p[0]);
+
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"sleep 30\"}",
+	    artifact_ids[1], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_job_id != old_job &&
+	    job_test_record_count(ctx->jobs) == 1 &&
+	    job_test_tombstone_count(ctx->jobs) == 1);
+	memset(&drop, 0, sizeof(drop));
+	correlation = 0;
+	CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 1 &&
+	    correlation == old_correlation && drop.job_id == (uint32_t)old_job &&
+	    strcmp(drop.artifact_id, artifact_ids[0]) == 0);
+	CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 0);
+	managed_clear();
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job);
+	run("shell_output", arg);
+	CHECK(err_flag && outhas("no such job"));
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == -1);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[2]) == -1);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[2]) == -1);
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation + 1, &drop) == -1);
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == 0 &&
+	    job_test_tombstone_count(ctx->jobs) == 0);
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == -1);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	/* STORE_ERROR races retire the same kind of current DROP tombstone with
+	 * one exact CANCEL instead of DROP_ACK.  When the DROP is still unsent,
+	 * cancellation removes it before main emits the exact CANCEL_ACK.  The two
+	 * terminal controls are mutually exclusive, and RELEASE is never valid for
+	 * an unpolled current spool. */
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p) == 0);
+	nonblock_fd(p[0]);
+	managed_run("shell_bg", "{\"command\":\"printf cancelled-drop\"}",
+	    artifact_ids[5], p[1], 1);
+	old_job = ctx->artifact_job_id;
+	old_correlation = ctx->artifact_correlation;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	settled = 0;
+	for (i = 0; i < 200; i++) {
+		job_pump(ctx->jobs);
+		(void)drain_bytes(p[0]);
+		if (job_test_state(ctx->jobs, old_job, &state) == 0 &&
+		    state.exited && !state.producer_open && !state.artifact_open &&
+		    state.pending_bytes == 0) {
+			settled = 1;
+			break;
+		}
+		usleep(5000);
+	}
+	CHECK(settled);
+	close(p[0]);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"sleep 30\"}",
+	    artifact_ids[6], nullfd, 1);
+	CHECK(!err_flag && job_test_record_count(ctx->jobs) == 1);
+	memset(&drop, 0, sizeof(drop));
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[5]) == 0 &&
+	    job_test_tombstone_count(ctx->jobs) == 0);
+	correlation = 0;
+	CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 0);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[5]) == -1);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[5]) == -1);
+	drop.job_id = (uint32_t)old_job;
+	strlcpy(drop.artifact_id, artifact_ids[5], sizeof(drop.artifact_id));
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	/* Live current keys, prior-poll history, and tombstones share one global
+	 * ownership bound.  A repeated running poll at the bound stops without
+	 * adopting another key and retains the current key as one settlement slot. */
+	job_test_set_ownership_limit(ctx->jobs, 2);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"printf bounded; sleep 30\"}",
+	    artifact_ids[0], nullfd, 1);
+	old_job = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 1);
+	managed_clear();
+	for (i = 0; i < 20; i++) {
+		job_pump(ctx->jobs);
+		usleep(5000);
+	}
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[1], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_spool_adopted &&
+	    ctx->artifact_result_available &&
+	    strcmp(ctx->artifact_result_id, artifact_ids[0]) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 2);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[2], nullfd, 1);
+	CHECK(err_flag && ctx->artifact_captured &&
+	    ctx->artifact_termination == ARTIFACT_TERM_CANCELLED &&
+	    !ctx->artifact_spool_adopted && ctx->artifact_result_available &&
+	    strcmp(ctx->artifact_result_id, artifact_ids[1]) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 2 &&
+	    job_test_record_count(ctx->jobs) == 1);
+	managed_clear();
+	run("shell_output", arg);
+	CHECK(err_flag && outhas("awaiting Artifact settlement") &&
+	    job_test_ownership_count(ctx->jobs) == 2);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 1);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == -1);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == -1);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[1]) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 0 &&
+	    job_test_record_count(ctx->jobs) == 0);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[1]) == -1);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[1]) == -1);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	/* The same no-replacement result identifies an unavailable old spool by
+	 * job plus the all-zero id.  Tool has no exact available key to settle;
+	 * the Coordinator can retire its unavailable reservation from this result. */
+	job_test_set_ownership_limit(ctx->jobs, 1);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	managed_run("shell_bg", "{\"command\":\"sleep 30\"}",
+	    artifact_ids[2], -1, 0);
+	old_job = ctx->artifact_job_id;
+	CHECK(!err_flag && old_job > 0 && job_turn_commit(ctx->jobs) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 1);
+	managed_clear();
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[3], nullfd, 1);
+	CHECK(err_flag && ctx->artifact_captured &&
+	    ctx->artifact_termination == ARTIFACT_TERM_CANCELLED &&
+	    ctx->artifact_job_id == old_job && !ctx->artifact_spool_adopted &&
+	    !ctx->artifact_result_available && ctx->artifact_result_id[0] == '\0' &&
+	    job_test_ownership_count(ctx->jobs) == 0 &&
+	    job_test_record_count(ctx->jobs) == 0);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	/* One valid STORE_ERROR cancellation is job-wide.  Matching an old live
+	 * history key retires every other history and the current replacement; no
+	 * second control for that job may succeed. */
+	job_test_set_ownership_limit(ctx->jobs, 16);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"sleep 30\"}",
+	    artifact_ids[0], nullfd, 1);
+	old_job = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[1], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_spool_adopted);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[2], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_spool_adopted &&
+	    job_test_ownership_count(ctx->jobs) == 3);
+	managed_clear();
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 0);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == -1);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[1]) == -1);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[2]) == -1);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[3], nullfd, 1);
+	CHECK(err_flag && ctx->artifact_termination == ARTIFACT_TERM_STORAGE_ERROR &&
+	    job_test_record_count(ctx->jobs) == 0);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	/* The same job-wide rule covers an archived job with both historical
+	 * tombstones and a published current DROP.  Socket ordering puts that DROP
+	 * before main's exact CANCEL_ACK; cancellation then makes DROP_ACK and every
+	 * sibling control stale. */
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"printf archived; sleep 1\"}",
+	    artifact_ids[0], nullfd, 1);
+	old_job = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_output", arg, artifact_ids[1], nullfd, 1);
+	old_correlation = ctx->artifact_correlation;
+	CHECK(!err_flag && ctx->artifact_spool_adopted &&
+	    job_test_ownership_count(ctx->jobs) == 2);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	settled = 0;
+	for (i = 0; i < 300; i++) {
+		job_pump(ctx->jobs);
+		if (job_test_state(ctx->jobs, old_job, &state) == 0 &&
+		    state.exited && !state.producer_open && !state.artifact_open &&
+		    state.pending_bytes == 0) {
+			settled = 1;
+			break;
+		}
+		usleep(5000);
+	}
+	CHECK(settled);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"sleep 30\"}",
+	    artifact_ids[4], nullfd, 1);
+	CHECK(!err_flag && job_test_record_count(ctx->jobs) == 1 &&
+	    job_test_tombstone_count(ctx->jobs) == 2);
+	memset(&drop, 0, sizeof(drop));
+	correlation = 0;
+	CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 1 &&
+	    correlation == old_correlation && drop.job_id == (uint32_t)old_job &&
+	    strcmp(drop.artifact_id, artifact_ids[1]) == 0);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[0]) == 0 &&
+	    job_test_tombstone_count(ctx->jobs) == 0);
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[0]) == -1);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, artifact_ids[1]) == -1);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 0);
+
+	/* Unavailable reservations are retained ownership too.  Their retirement
+	 * uses the same exact correlation/job DROP, with only an all-zero id valid. */
+	job_test_set_ownership_limit(ctx->jobs, 16);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	managed_run("shell_bg", "{\"command\":\"printf unavailable-drop\"}",
+	    artifact_ids[3], -1, 0);
+	old_job = ctx->artifact_job_id;
+	old_correlation = ctx->artifact_correlation;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 1);
+	managed_clear();
+	settled = 0;
+	for (i = 0; i < 200; i++) {
+		job_pump(ctx->jobs);
+		if (job_test_state(ctx->jobs, old_job, &state) == 0 &&
+		    state.exited && !state.producer_open && !state.artifact_open &&
+		    state.pending_bytes == 0) {
+			settled = 1;
+			break;
+		}
+		usleep(5000);
+	}
+	CHECK(settled);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"sleep 30\"}",
+	    artifact_ids[4], nullfd, 1);
+	CHECK(!err_flag && job_test_record_count(ctx->jobs) == 1 &&
+	    job_test_tombstone_count(ctx->jobs) == 1);
+	memset(&drop, 0, sizeof(drop));
+	correlation = 0;
+	CHECK(job_artifact_drop_next(ctx->jobs, &correlation, &drop) == 1 &&
+	    correlation == old_correlation && drop.job_id == (uint32_t)old_job &&
+	    memcmp(drop.artifact_id, zero_id, sizeof(zero_id)) == 0);
+	CHECK(job_artifact_cancel(ctx->jobs, old_job, drop.artifact_id) == -1);
+	bad_drop = drop;
+	bad_drop.artifact_id[0] = 'a';
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &bad_drop) == -1);
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == 0 &&
+	    job_test_tombstone_count(ctx->jobs) == 0);
+	CHECK(job_artifact_drop_ack(ctx->jobs, old_correlation, &drop) == -1);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0 &&
+	    job_test_ownership_count(ctx->jobs) == 0);
+
+	/* A full exact-key tombstone table cannot grow without bound.  With no
+	 * running jobs, admission fails as settlement backlog rather than
+	 * pretending the independent max_bg_jobs concurrency gate was reached. */
+	job_test_set_tombstone_limit(ctx->jobs, 0);
+	CHECK(job_turn_begin(ctx->jobs) == 0 && pipe(p) == 0);
+	nonblock_fd(p[0]);
+	managed_run("shell_bg", "{\"command\":\"printf held-by-backlog\"}",
+	    artifact_ids[2], p[1], 1);
+	old_job = ctx->artifact_job_id;
+	CHECK(!err_flag && job_turn_commit(ctx->jobs) == 0);
+	managed_clear();
+	settled = 0;
+	for (i = 0; i < 200; i++) {
+		job_pump(ctx->jobs);
+		(void)drain_bytes(p[0]);
+		if (job_test_state(ctx->jobs, old_job, &state) == 0 &&
+		    state.exited && !state.producer_open && !state.artifact_open &&
+		    state.pending_bytes == 0) {
+			settled = 1;
+			break;
+		}
+		usleep(5000);
+	}
+	CHECK(settled);
+	close(p[0]);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	managed_run("shell_bg", "{\"command\":\"sleep 30\"}",
+	    artifact_ids[3], nullfd, 1);
+	CHECK(err_flag && outhas("settlement backlog") &&
+	    !outhas("background-job cap") &&
+	    job_test_record_count(ctx->jobs) == 1 &&
+	    job_test_tombstone_count(ctx->jobs) == 0);
+	managed_clear();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+
+	/* A final poll can still settle that retained live record; once its exact
+	 * control arrives, no tombstone is needed and the record disappears. */
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	nullfd = open("/dev/null", O_WRONLY);
+	CHECK(nullfd != -1);
+	(void)snprintf(arg, sizeof(arg), "{\"id\":%d}", old_job);
+	managed_run("shell_output", arg, artifact_ids[4], nullfd, 1);
+	CHECK(!err_flag && ctx->artifact_termination == ARTIFACT_TERM_EXIT &&
+	    job_test_record_count(ctx->jobs) == 1);
+	CHECK(job_artifact_release(ctx->jobs, old_job, artifact_ids[2]) == 0 &&
+	    job_test_record_count(ctx->jobs) == 0);
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	managed_clear();
+
+	tool_ctx_free(bounded);
+	ctx = saved;
+}
+
+static void
+test_background_pump_fairness(void)
+{
+	struct tool_ctx *saved = ctx, *fair;
+	int i, round, seen[3] = { 0, 0, 0 };
+
+	fair = tool_ctx_new(1, 0);
+	ctx = fair;
+	job_set_cap(ctx->jobs, 3);
+	CHECK(job_test_pump_per_job_limit() == 64 * 1024);
+	CHECK(job_test_pump_total_limit() == 1024 * 1024);
+	CHECK(job_turn_begin(ctx->jobs) == 0);
+	for (i = 0; i < 3; i++) {
+		run("shell_bg", "{\"command\":\"yes fair\"}");
+		CHECK(!err_flag && ctx->artifact_job_id == i + 1);
+	}
+	for (round = 0; round < 200; round++) {
+		for (i = 0; i < 3; i++)
+			if (job_test_output_bytes(ctx->jobs, i + 1) < 4096)
+				break;
+		if (i == 3)
+			break;
+		usleep(5000);
+	}
+	CHECK(i == 3);
+	/* Compress the same scheduler policy so two jobs saturate one pump;
+	 * the next pump must begin with the skipped third job. */
+	job_test_set_pump_limits(4096, 8192);
+	for (round = 0; round < 2; round++) {
+		job_pump(ctx->jobs);
+		CHECK(job_test_last_pump_total(ctx->jobs) == 8192);
+		for (i = 0; i < 3; i++) {
+			size_t n = job_test_last_pump_bytes(ctx->jobs, i + 1);
+
+			CHECK(n <= 4096);
+			if (n > 0)
+				seen[i] = 1;
+		}
+		if (round == 0)
+			CHECK(job_test_last_pump_bytes(ctx->jobs, 1) == 4096 &&
+			    job_test_last_pump_bytes(ctx->jobs, 2) == 4096 &&
+			    job_test_last_pump_bytes(ctx->jobs, 3) == 0);
+		else
+			CHECK(job_test_last_pump_bytes(ctx->jobs, 3) == 4096);
+	}
+	CHECK(seen[0] && seen[1] && seen[2]);
+	job_test_reset_pump_limits();
+	CHECK(job_turn_abort(ctx->jobs) == 0);
+	tool_ctx_free(fair);
+	ctx = saved;
+}
+
 int
 main(int argc, char *argv[])
 {
+	struct sigaction sa;
+
 	log_init(LOG_TO_STDERR, 0, LOG_USER);
 	if (argc != 2)
 		errx(1, "usage: tools_test <tree>");
 	if (chdir(argv[1]) == -1)
 		err(1, "chdir %s", argv[1]);
+	memset(&sa, 0, sizeof(sa));
+	sa.sa_handler = cancel_active_signal;
+	sigemptyset(&sa.sa_mask);
+	if (sigaction(SIGUSR1, &sa, NULL) == -1)
+		err(1, "sigaction SIGUSR1");
 
 	buf_init(&out);
 
@@ -555,6 +1465,9 @@ main(int argc, char *argv[])
 	CHECK(err_flag && outhas("no such job"));     /* abort removed it */
 	run("shell_kill", "{\"id\":999}");
 	CHECK(err_flag && outhas("no such job"));
+	test_background_pump_fairness();
+	test_managed_artifacts();
+	test_bounded_job_retirement();
 
 	/* --- unknown tool ----------------------------------------- */
 	run("frobnicate", "{}");
blob - 262a0930c5ca8ea077a200efe4dcabb185a30b21
blob + 7ec62a2cb9334534b80aa371ac9fb0dd5858aae6
--- regress/turn/reqcheck.c
+++ regress/turn/reqcheck.c
@@ -62,6 +62,34 @@ string_is(const struct json *j, int tok, const char *w
 	return (ok);
 }
 
+static int
+artifact_text_is(const struct json *j, int tok, const char *want)
+{
+	struct json nested;
+	char *serialized;
+	size_t len;
+	int root, ok = 0;
+
+	if (tok == -1 || !json_is_string(j, tok) ||
+	    (serialized = json_get_str(j, tok, &len)) == NULL)
+		return (0);
+	if (json_parse(&nested, serialized, len, 0) != 0)
+		goto done;
+	root = json_root(&nested);
+	ok = json_is_object(&nested, root) &&
+	    string_is(&nested, json_obj_get(&nested, root, "preview_head"),
+	    want) &&
+	    string_is(&nested, json_obj_get(&nested, root, "preview_tail"), "") &&
+	    string_is(&nested, json_obj_get(&nested, root, "preview_encoding"),
+	    "utf-8") &&
+	    json_get_bool(&nested,
+	    json_obj_get(&nested, root, "preview_abbreviated")) == 0;
+	json_done(&nested);
+done:
+	free(serialized);
+	return (ok);
+}
+
 static const u_char *
 request_body(const struct buf *b, size_t *len)
 {
@@ -134,7 +162,8 @@ tool_text_message_is(const struct json *j, int msg, co
 	    !json_is_object(j, block) ||
 	    !string_is(j, json_obj_get(j, block, "type"), "tool_result") ||
 	    !string_is(j, json_obj_get(j, block, "tool_use_id"), tool_id) ||
-	    !string_is(j, json_obj_get(j, block, "content"), "slept-ok\n") ||
+	    !artifact_text_is(j, json_obj_get(j, block, "content"),
+	    "slept-ok\n") ||
 	    (block = json_arr_get(j, content, 1)) == -1 ||
 	    !json_is_object(j, block) ||
 	    !string_is(j, json_obj_get(j, block, "type"), "text") ||
@@ -180,7 +209,8 @@ tool_continuation_is(const struct json *j, int message
 	    !json_is_object(j, block) ||
 	    !string_is(j, json_obj_get(j, block, "type"), "tool_result") ||
 	    !string_is(j, json_obj_get(j, block, "tool_use_id"), "toolu_zz") ||
-	    !string_is(j, json_obj_get(j, block, "content"), "slept-ok\n"))
+	    !artifact_text_is(j, json_obj_get(j, block, "content"),
+	    "slept-ok\n"))
 		return (0);
 	return (1);
 }
blob - a59368d8a61b93cd383478cf92563bee950aa163
blob + 47b00ed92f85eeb33c03f3fb4ad6a84b91973756
--- regress/turn/run.sh
+++ regress/turn/run.sh
@@ -33,10 +33,13 @@ fail() { checks=$((checks + 1)); fails=$((fails + 1));
 ok()   { if [ "$2" -eq 0 ]; then pass "$1"; else fail "$1"; fi; }
 
 dir=$(mktemp -d /tmp/fugu-turn.XXXXXXXXXX)
+test_home=$(mktemp -d /tmp/fugu-turn-home.XXXXXXXXXX)
 stubpid=''
+fugupid=''
 cleanup() {
+	[ -n "$fugupid" ] && { kill "$fugupid" 2>/dev/null; wait "$fugupid" 2>/dev/null; }
 	[ -n "$stubpid" ] && { kill "$stubpid" 2>/dev/null; wait "$stubpid" 2>/dev/null; }
-	rm -rf "$dir"
+	rm -rf "$dir" "$test_home"
 }
 trap cleanup EXIT INT TERM
 
@@ -49,7 +52,7 @@ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curv
 # stage the real binaries
 lx=$dir/libexec/fugu
 mkdir -p "$lx"
-for w in fugu-tty fugu-api fugu-web fugu-tool fugu-editor; do
+for w in fugu-tty fugu-api fugu-web fugu-tool fugu-artifact fugu-editor; do
 	b=$root/src/$w/obj/$w
 	[ -x "$b" ] || b=$root/src/$w/$w
 	[ -x "$b" ] || { echo "FAIL - $w not built"; exit 1; }
@@ -58,8 +61,7 @@ done
 fugu=$root/src/fugu/obj/fugu
 [ -x "$fugu" ] || fugu=$root/src/fugu/fugu
 
-export HOME=$dir/home
-mkdir -p "$HOME"
+export HOME=$test_home
 KEY=sk-test-abc123XYZ
 
 start_stub() {	# $1 = scenario words
@@ -168,6 +170,76 @@ done
 cp "$dir/fugu-editor.real" "$lx/fugu-editor"
 stop_stub
 
+# The Lead Tool's project and fixed runtime unveils must never cover managed
+# Artifact storage.  Refuse each physical overlap before spawning a worker,
+# opening a Journal, or contacting the Provider.
+owner_home=$HOME
+rm -rf "$owner_home/.fugu"
+rm -f "$dir/reqout"
+start_stub "OK"
+write_conf "$stubport"
+( cd "$owner_home" && FUGU_CONF=$dir/fugu.conf \
+    FUGU_LIBEXEC=$dir/libexec/fugu FUGU_CA_FILE=$dir/cert.pem \
+    "$fugu" -p "scope probe" </dev/null >"$dir/out" 2>"$dir/errout" )
+rc=$?
+ok "Artifact scope: HOME as project root is refused" \
+    $([ "$rc" -ne 0 ]; echo $?)
+grep -q "Artifact storage overlaps Tool filesystem authority" "$dir/errout"
+ok "Artifact scope: overlap refusal is conspicuous" $?
+! test -e "$dir/reqout" &&
+    ! ls "$owner_home/.fugu/sessions"/*.ndjson >/dev/null 2>&1
+ok "Artifact scope: refusal precedes Journal and Provider effects" $?
+stop_stub
+
+rm -rf "$owner_home/.fugu"
+mkdir -p "$owner_home/.fugu/artifacts/project"
+printf 'private-marker\n' > "$owner_home/.fugu/artifacts/raw-marker"
+rm -f "$dir/reqout"
+start_stub "OK"
+write_conf "$stubport"
+( cd "$owner_home/.fugu/artifacts/project" && \
+    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+    FUGU_CA_FILE=$dir/cert.pem "$fugu" -p "inside probe" \
+    </dev/null >"$dir/out" 2>"$dir/errout" )
+rc=$?
+ok "Artifact scope: project inside the managed root is refused" \
+    $([ "$rc" -ne 0 ]; echo $?)
+test "$(cat "$owner_home/.fugu/artifacts/raw-marker")" = private-marker &&
+    ! test -e "$dir/reqout"
+ok "Artifact scope: refusal leaves raw storage untouched" $?
+stop_stub
+
+rm -rf "$owner_home/.fugu" "$dir/home-alias"
+ln -s "$owner_home" "$dir/home-alias"
+rm -f "$dir/reqout"
+start_stub "OK"
+write_conf "$stubport"
+( cd "$owner_home" && HOME="$dir/home-alias" \
+    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+    FUGU_CA_FILE=$dir/cert.pem "$fugu" -p "alias probe" \
+    </dev/null >"$dir/out" 2>"$dir/errout" )
+rc=$?
+ok "Artifact scope: HOME symlink aliases cannot hide an overlap" \
+    $([ "$rc" -ne 0 ]; echo $?)
+! test -e "$dir/reqout" &&
+    ! ls "$owner_home/.fugu/sessions"/*.ndjson >/dev/null 2>&1
+ok "Artifact scope: aliased refusal has no external side effect" $?
+stop_stub
+
+rm -f "$dir/reqout"
+start_stub "OK"
+write_conf "$stubport"
+HOME=/usr FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+    FUGU_CA_FILE=$dir/cert.pem "$fugu" -p "fixed root probe" \
+    </dev/null >"$dir/out" 2>"$dir/errout"
+rc=$?
+ok "Artifact scope: HOME below a fixed Tool unveil is refused" \
+    $([ "$rc" -ne 0 ]; echo $?)
+grep -q "Artifact storage overlaps Tool filesystem authority" "$dir/errout" &&
+    ! test -e "$dir/reqout"
+ok "Artifact scope: fixed-root refusal precedes Provider access" $?
+stop_stub
+
 # --- 1: a clean OK turn ---------------------------------------------
 start_stub "OK"
 write_conf "$stubport"
@@ -563,6 +635,87 @@ ok "tool loop: first and continuation Generations reta
     $([ "$n" -eq 2 ]; echo $?)
 stop_stub
 
+# --- 13-artifact: abbreviated raw output stays path-free -------------
+rm -rf "$HOME/.fugu"
+start_stub "ARTIFACTTOOL OUTPUTREAD OUTPUTREADDEFAULT OK"
+write_conf "$stubport"
+run_fugu -p "produce retained output"
+ok "artifact tool loop: abbreviated result completes" \
+    $([ "$rc" -eq 0 ]; echo $?)
+request_log_entry 2 "$dir/artifact-request"
+grep -Fq '\"preview_abbreviated\":true' "$dir/artifact-request"
+ok "artifact tool loop: next Generation receives an abbreviated result" $?
+grep -Eq '\\"id\\":\\"[0-9a-f]{32}\\"' "$dir/artifact-request"
+ok "artifact tool loop: result exposes only an opaque output id" $?
+journal=$(ls "$sessions"/*.ndjson 2>/dev/null | head -1)
+grep -a -E '(\.fugu/artifacts|\.pending-)' \
+    "$dir/artifact-request" "$journal" "$dir/out" "$dir/errout" \
+    >/dev/null 2>&1
+ok "artifact tool loop: no private storage path crosses runtime surfaces" \
+    $([ $? -ne 0 ]; echo $?)
+grep -q '"t":"artifact"' "$journal"
+ok "artifact tool loop: the structured Artifact record commits" $?
+request_log_entry 3 "$dir/output-read-request"
+grep -Fq '"name":"output_read"' "$dir/output-read-request" &&
+    grep -Fq '"offset":65536' "$dir/output-read-request" &&
+    grep -Fq '"limit":4' "$dir/output-read-request" &&
+    grep -Fq '"encoding":"base64"' "$dir/output-read-request"
+ok "output_read loop: Provider arguments cross the Coordinator seam" $?
+grep -Fq '\"offset\":65536' "$dir/output-read-request" &&
+    grep -Fq '\"returned_bytes\":4' "$dir/output-read-request" &&
+    grep -Fq '\"next_offset\":65540' "$dir/output-read-request" &&
+    grep -Fq '\"state\":\"pending\"' "$dir/output-read-request" &&
+    grep -Fq '\"data\":\"QQD/Qg==\"' \
+    "$dir/output-read-request"
+ok "output_read loop: pending base64 page reaches the next Generation" $?
+request_log_entry 4 "$dir/output-read-default-request"
+grep -Eq '"id":"toolu_output_read_default","name":"output_read",'\
+'"input":\{"id":"[0-9a-f]{32}"\}' "$dir/output-read-default-request" &&
+    grep -Fq '\"offset\":0' "$dir/output-read-default-request" &&
+    grep -Fq '\"returned_bytes\":65536' \
+    "$dir/output-read-default-request" &&
+    grep -Fq '\"next_offset\":65536' \
+    "$dir/output-read-default-request" &&
+    grep -Fq '\"encoding\":\"text\"' \
+    "$dir/output-read-default-request" &&
+    grep -Fq '\"state\":\"pending\"' \
+    "$dir/output-read-default-request"
+ok "output_read loop: id-only call applies text and 64 KiB defaults" $?
+stop_stub
+
+# A read response remains an exact bounded transaction after Ctrl-C.  The
+# regression-only Role delay widens the cancellation window; Coordinator must
+# drain DATA/READ_RESULT before Artifact Turn abort reuses the same channel.
+rm -rf "$HOME/.fugu"
+start_stub "ARTIFACTTOOL OUTPUTREAD OK"
+write_conf "$stubport"
+FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+    FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \
+    FUGU_ARTIFACT_READ_DELAY_MS=1000 \
+    "$fugu" -p "cancel retained output read" </dev/null \
+    >"$dir/out" 2>"$dir/errout" &
+fugupid=$!
+i=0
+while ! grep -q '^--- OUTPUTREAD ---$' "$dir/reqlog" 2>/dev/null &&
+    [ "$i" -lt 100 ]; do
+	sleep 0.05
+	i=$((i + 1))
+done
+sleep 0.1
+/bin/kill -INT "$fugupid" 2>/dev/null
+wait "$fugupid"
+rc=$?
+fugupid=''
+ok "output_read cancel: interrupted read exits 3" \
+    $([ "$rc" -eq 3 ]; echo $?)
+if grep -Eq 'bad Artifact read|unexpected Artifact event' \
+    "$dir/errout" 2>/dev/null; then
+	fail "output_read cancel: exact delayed response drains before rollback"
+else
+	pass "output_read cancel: exact delayed response drains before rollback"
+fi
+stop_stub
+
 # --- 13a: a full brokered web loop over the real mesh (M8) ----------
 # The development-only private-address seam lets the isolated fixture act
 # as both provider and web origin; installed/setgid binaries ignore it.
@@ -1019,7 +1172,7 @@ stop_stub
 
 # --- 23b: automatic Compaction preflight ---------------------------
 # With the minimal five-tool catalogue below, the conservative estimate for
-# System "S" plus User "strict boundary" is exactly 463 tokens.  Equality at
+# System "S" plus User "strict boundary" is exactly 591 tokens.  Equality at
 # window-reserve is intentionally not a cut.
 rm -rf "$HOME/.fugu"
 start_stub "OK"
@@ -1036,7 +1189,7 @@ allow_write no
 web_search no
 max_bg_jobs 0
 max_subagents 0
-context_limit 464
+context_limit 592
 compact_reserve_tokens 1
 compact_keep_tokens 1
 auto_compact yes
@@ -1100,9 +1253,9 @@ ok "auto Compaction disabled: only the requested Gener
     $([ "$n" -eq 1 ]; echo $?)
 stop_stub
 
-# A 1,000-byte first User request is 709 estimated tokens, below the 750-token
-# threshold.  Its completed pair plus the second User reaches 765; replacing
-# that history with short notes falls to 502 before the second Generation.
+# A 1,000-byte first User request is 837 estimated tokens, below the 878-token
+# threshold.  Its completed pair plus the second User reaches 893; replacing
+# that history with short notes falls to 630 before the second Generation.
 rm -rf "$HOME/.fugu"
 start_stub "OK SUMMARY OK"
 cat > "$dir/fugu.conf" <<EOF
@@ -1118,7 +1271,7 @@ allow_write no
 web_search no
 max_bg_jobs 0
 max_subagents 0
-context_limit 751
+context_limit 879
 compact_reserve_tokens 1
 compact_keep_tokens 1
 auto_compact yes
@@ -1140,9 +1293,10 @@ stop_stub
 
 # A fresh projection is estimated again after Tool results.  The 1,400-byte
 # Owner prefix fits before the Tool call, the pair crosses the threshold, and
-# replacing that prefix with short notes falls below it again.  The retained
-# Assistant/result pair is not re-executed; only the summary and continuation
-# Generation are additional Provider calls.
+# replacing that prefix with short notes falls below it again.  The 150-token
+# suffix budget retains the generated Artifact result with its Assistant; the
+# pair is not re-executed, and only the summary and continuation Generation are
+# additional Provider calls.
 rm -rf "$HOME/.fugu"
 start_stub "TOOLUSE SUMMARY OK"
 cat > "$dir/fugu.conf" <<EOF
@@ -1158,9 +1312,9 @@ allow_write no
 web_search no
 max_bg_jobs 0
 max_subagents 0
-context_limit 851
+context_limit 979
 compact_reserve_tokens 1
-compact_keep_tokens 100
+compact_keep_tokens 150
 auto_compact yes
 effort "high"
 EOF
@@ -2371,5 +2525,235 @@ ok "background rollback: exactly one turn committed" \
     $([ "$n" = 1 ]; echo $?)
 stop_stub
 
+# A committed job is polled in a later Turn.  Its replacement spool is
+# detached by the out-of-band ROTATE frame before the ordinary Tool result;
+# settling the frozen segment must neither duplicate DETACH nor lose the job.
+rm -rf "$HOME/.fugu"
+rm -f "$dir/reqout"
+start_stub "BGTOOL OK JOBOUTPUT OK"
+write_conf "$stubport"
+printf 'launch committed background\nprobe committed background\n/quit\n' \
+    > "$dir/lin"
+run_fugu_in "$dir/lin"
+ok "background rotation: committed job poll completes" \
+    $([ "$rc" -eq 0 ]; echo $?)
+grep -q '"tool_use_id":"toolu_poll"' "$dir/reqout" 2>/dev/null
+ok "background rotation: frozen result reaches the provider" $?
+n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null)
+ok "background rotation: both Turns commit" \
+    $([ "$n" = 2 ]; echo $?)
+if grep -Eq 'bad Artifact|mismatched|duplicate' "$dir/errout" 2>/dev/null; then
+	fail "background rotation: ownership protocol stays singular"
+else
+	pass "background rotation: ownership protocol stays singular"
+fi
+stop_stub
+
+# A committed short job retires its current available spool while the Lead is
+# waiting through a Provider retry.  DROP is asynchronous to Tool results;
+# Coordinator must discard only that exact current spool and ACK it once.
+rm -rf "$HOME/.fugu"
+rm -f "$dir/reqout"
+start_stub "BGSHORT OK RETRY429 OK"
+write_conf "$stubport"
+printf 'launch short background\nwait for available retirement\n/quit\n' \
+    > "$dir/lin"
+run_fugu_in "$dir/lin"
+ok "background retirement: available DROP round-trip completes" \
+    $([ "$rc" -eq 0 ]; echo $?)
+n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null)
+ok "background retirement: both available-DROP Turns commit" \
+    $([ "$n" = 2 ]; echo $?)
+if grep -Eq 'bad Artifact|mismatched|stale Artifact' \
+    "$dir/errout" 2>/dev/null; then
+	fail "background retirement: available key is acknowledged once"
+else
+	pass "background retirement: available key is acknowledged once"
+fi
+stop_stub
+
+# Eight detached logical reservations fill the 128 MiB Session quota without
+# writing 128 MiB of physical data.  The ninth job receives an unavailable
+# zero-id reservation, commits, and later retires it over the same exact
+# DROP/ACK transaction without waiting for an Artifact terminal.
+rm -rf "$HOME/.fugu"
+rm -f "$dir/reqout"
+scenario=''
+: > "$dir/lin"
+i=0
+while [ "$i" -lt 8 ]; do
+	scenario="$scenario BGTOOL OK"
+	printf 'reserve background spool %s\n' "$i" >> "$dir/lin"
+	i=$((i + 1))
+done
+scenario="$scenario BGSHORT OK RETRY429 OK"
+printf 'launch unavailable short background\nwait for unavailable retirement\n/quit\n' \
+    >> "$dir/lin"
+start_stub "$scenario"
+write_conf "$stubport"
+printf 'max_bg_jobs 9\n' >> "$dir/fugu.conf"
+run_fugu_in "$dir/lin"
+ok "background retirement: unavailable DROP round-trip completes" \
+    $([ "$rc" -eq 0 ]; echo $?)
+n=$(grep -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null)
+ok "background retirement: all unavailable-DROP Turns commit" \
+    $([ "$n" = 10 ]; echo $?)
+if grep -Eq 'bad Artifact|mismatched|stale Artifact' \
+    "$dir/errout" 2>/dev/null; then
+	fail "background retirement: zero-id key is acknowledged once"
+else
+	pass "background retirement: zero-id key is acknowledged once"
+fi
+stop_stub
+
+# A ROTATE frame is ownership state in its own right.  Publish it with a
+# blocking selftest send, then cancel before the ordinary Tool result.  The
+# background job is still pending in this Turn, so rollback must consume the
+# frame without trying to release committed job ownership.
+rm -rf "$HOME/.fugu"
+rm -f "$dir/out" "$dir/errout" "$dir/reqout"
+start_stub "BGTOOL JOBOUTPUT"
+write_conf "$stubport"
+FUGU_SELFTEST=1 FUGU_ARTIFACT_FRAME0_DELAY_MS=3000 \
+    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+    FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \
+    "$fugu" -d -p "cancel pending Artifact rotation" </dev/null \
+    >"$dir/out" 2>"$dir/errout" &
+fugupid=$!
+i=0
+while ! grep -Fq 'selftest frame0 Artifact rotation published' \
+    "$dir/errout" 2>/dev/null && [ "$i" -lt 200 ] && \
+    kill -0 "$fugupid" 2>/dev/null; do
+	sleep 0.05
+	i=$((i + 1))
+done
+grep -Fq 'selftest frame0 Artifact rotation published' \
+    "$dir/errout" 2>/dev/null
+reached=$?
+if [ "$reached" -eq 0 ]; then
+	/bin/kill -INT "$fugupid" 2>/dev/null
+else
+	/bin/kill -TERM "$fugupid" 2>/dev/null
+fi
+wait "$fugupid"
+rc=$?
+fugupid=''
+ok "background rotation cancel: pending frame publishes before Ctrl-C" \
+    "$reached"
+ok "background rotation cancel: pending Turn exits 3" \
+    $([ "$rc" -eq 3 ]; echo $?)
+n=$(grep -h -c '"outcome":"abandoned"' \
+    "$sessions"/*.ndjson 2>/dev/null | awk '{ n += $1 } END { print n + 0 }')
+ok "background rotation cancel: pending Turn is abandoned once" \
+    $([ "$n" -eq 1 ]; echo $?)
+n=$(grep -h -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null | \
+    awk '{ n += $1 } END { print n + 0 }')
+ok "background rotation cancel: pending job never commits" \
+    $([ "$n" -eq 0 ]; echo $?)
+n=$(grep -Fc 'selftest frame0 Artifact rotation published' \
+    "$dir/errout" 2>/dev/null)
+ok "background rotation cancel: pending frame publishes exactly once" \
+    $([ "$n" -eq 1 ]; echo $?)
+if grep -Eq 'selftest Artifact RELEASE|bad Artifact|mismatched Artifact|stale Artifact|missing cancelled Artifact' \
+    "$dir/errout" 2>/dev/null; then
+	fail "background rotation cancel: pending ownership rolls back cleanly"
+else
+	pass "background rotation cancel: pending ownership rolls back cleanly"
+fi
+stop_stub
+
+# The same cancellation after the launch Turn commits must preserve the
+# background job but release the frozen side of the published rotation.
+# Curses routes Ctrl-C through the normal UI cancellation handshake and exits
+# only after Coordinator has settled that ownership frame.
+if [ -x "$obj/ptydrive" ]; then
+	rm -rf "$HOME/.fugu"
+	rm -f "$dir/reqout" "$dir/reqlog" "$dir/rotation-cursesout"
+	start_stub "BGTOOL OK JOBOUTPUT"
+	write_conf "$stubport"
+	printf 'launch committed background\r\000\000\000poll committed background\r\000\003' | \
+	    TERM=vt100 HOME=$HOME FUGU_SELFTEST=1 \
+	    FUGU_ARTIFACT_FRAME0_DELAY_MS=3000 \
+	    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+	    FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \
+	    "$obj/ptydrive" "$fugu" -d \
+	    >"$dir/rotation-cursesout" 2>&1
+	rc=$?
+	ok "background rotation cancel: curses exits after Ctrl-C" \
+	    $([ "$rc" -eq 0 ]; echo $?)
+	n=$(grep -h -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null | \
+	    awk '{ n += $1 } END { print n + 0 }')
+	ok "background rotation cancel: launch Turn remains committed" \
+	    $([ "$n" -eq 1 ]; echo $?)
+	n=$(grep -h -c '"outcome":"abandoned"' \
+	    "$sessions"/*.ndjson 2>/dev/null | \
+	    awk '{ n += $1 } END { print n + 0 }')
+	ok "background rotation cancel: poll Turn is abandoned once" \
+	    $([ "$n" -eq 1 ]; echo $?)
+	n=$(grep -Fc 'selftest frame0 Artifact rotation published' \
+	    "$dir/rotation-cursesout" 2>/dev/null)
+	ok "background rotation cancel: committed frame publishes once" \
+	    $([ "$n" -eq 1 ]; echo $?)
+	n=$(grep -Fc 'selftest Artifact RELEASE job=1' \
+	    "$dir/rotation-cursesout" 2>/dev/null)
+	ok "background rotation cancel: committed frozen spool releases once" \
+	    $([ "$n" -eq 1 ]; echo $?)
+	if grep -Eq 'selftest Artifact CANCEL_ACK|bad Artifact|mismatched Artifact|stale Artifact|missing cancelled Artifact' \
+	    "$dir/rotation-cursesout" 2>/dev/null; then
+		fail "background rotation cancel: committed ownership settles cleanly"
+	else
+		pass "background rotation cancel: committed ownership settles cleanly"
+	fi
+	stop_stub
+else
+	fail "background rotation cancel: ptydrive helper not built"
+fi
+
+# Force a completed committed job into normal archival while its Artifact
+# terminal is delayed and converted to STORE_ERROR.  DROP is already queued
+# on the Tool socket when Coordinator sends CANCEL; the ordered Tool output
+# must therefore be DROP followed by the exact job-wide CANCEL_ACK.
+rm -rf "$HOME/.fugu"
+rm -f "$dir/out" "$dir/errout" "$dir/reqout"
+start_stub "BGFAST OK RETRY429 OK JOBOUTPUT OK"
+write_conf "$stubport"
+printf 'launch short background\nwait for store failure\nprobe cancelled background\n/quit\n' \
+    > "$dir/lin"
+FUGU_SELFTEST=1 FUGU_ARTIFACT_ARCHIVE_COMPLETED=1 \
+    FUGU_ARTIFACT_DROP_DELAY_MS=1500 \
+    FUGU_ARTIFACT_STORE_ERROR_DELAY_MS=500 \
+    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+    FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \
+    "$fugu" -d <"$dir/lin" >"$dir/out" 2>"$dir/errout"
+rc=$?
+ok "background retirement race: session completes" \
+    $([ "$rc" -eq 0 ]; echo $?)
+n=$(grep -h -c '"outcome":"ok"' "$sessions"/*.ndjson 2>/dev/null | \
+    awk '{ n += $1 } END { print n + 0 }')
+ok "background retirement race: all three Turns commit" \
+    $([ "$n" -eq 3 ]; echo $?)
+grep -q 'shell_output: no such job 1' "$dir/reqout" 2>/dev/null
+ok "background retirement race: failed job ownership is gone" $?
+drop_line=$(grep -n -m 1 'selftest Artifact DROP queued' \
+    "$dir/errout" 2>/dev/null | cut -d: -f1)
+error_line=$(grep -n -m 1 'selftest STORE_ERROR injected' \
+    "$dir/errout" 2>/dev/null | cut -d: -f1)
+ack_line=$(grep -n -m 1 'selftest Artifact CANCEL_ACK job=1' \
+    "$dir/errout" 2>/dev/null | cut -d: -f1)
+[ -n "$drop_line" ] && [ -n "$error_line" ] && [ -n "$ack_line" ] && \
+    [ "$drop_line" -lt "$error_line" ] && [ "$error_line" -lt "$ack_line" ]
+ok "background retirement race: DROP precedes STORE_ERROR and CANCEL_ACK" $?
+n=$(grep -Ec 'selftest Artifact DROP queued|selftest STORE_ERROR injected|selftest Artifact CANCEL_ACK job=1' \
+    "$dir/errout" 2>/dev/null)
+ok "background retirement race: each protocol trace occurs exactly once" \
+    $([ "$n" -eq 3 ]; echo $?)
+if grep -Eq 'bad Artifact|mismatched Artifact|stale Artifact|unexpected Artifact|worker .* exited' \
+    "$dir/errout" 2>/dev/null; then
+	fail "background retirement race: exact-key composition stays valid"
+else
+	pass "background retirement race: exact-key composition stays valid"
+fi
+stop_stub
+
 echo "$checks checks, $fails failures"
 [ "$fails" -eq 0 ]
blob - 2f966dd42bec9929878d783035555b9a72ec860e
blob + 4c738bf1b95897ccc589f844b521cd6d3f82746d
--- regress/turn/stub.c
+++ regress/turn/stub.c
@@ -26,8 +26,11 @@
  * stream with no content blocks), BADARGS (a complete but malformed
  * subagent tool input), PROTODRAIN (a malformed Generation followed by a
  * delayed Provider terminal), NOARGS (a subagent tool call with absent input),
- * WEBFETCH (a brokered web_fetch),
- * BGTOOL (shell_bg sleep 300), ERROR_BG (the provider failure after
+ * WEBFETCH (a brokered web_fetch), OUTPUTREAD (an explicit brokered Artifact
+ * page), OUTPUTREADDEFAULT (a defaults-only Artifact page),
+ * BGTOOL (shell_bg sleep 300), BGSHORT (shell_bg sleep 1),
+ * BGFAST (shell_bg immediate output),
+ * ERROR_BG (the provider failure after
  * that launch), JOBOUTPUT (shell_output job 1), DOUBLE (a hostile error event
  * appended after a complete OK stream), AGENTCALL (lead delegates to a
  * labelled subagent), AGENTREAD (the subagent reads a fixture), AGENTREPORT
@@ -205,6 +208,31 @@ static const char sse_tooluse[] =
 "event: message_stop\n"
 "data: {\"type\":\"message_stop\"}\n\n";
 
+/* A printable result just over the provider-visible ceiling must be replaced
+ * by Fugu's abbreviated Artifact document before the next Generation. */
+static const char sse_artifact_tool[] =
+"event: message_start\n"
+"data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+    "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n"
+"event: content_block_start\n"
+"data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_artifact\","
+    "\"name\":\"shell\"}}\n\n"
+"event: content_block_delta\n"
+"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"input_json_delta\",\"partial_json\":"
+    "\"{\\\"command\\\":\\\"perl -e 'print q(ARTIFACT-MARKER); "
+    "print q(x) x 65521; print pack(q(C*),65,0,255,66); "
+    "print q(ARTIFACT-MARKER) "
+    "x 24000'\\\"}\"}}\n\n"
+"event: content_block_stop\n"
+"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+"event: message_delta\n"
+"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"tool_use\"},\"usage\":{\"output_tokens\":12}}\n\n"
+"event: message_stop\n"
+"data: {\"type\":\"message_stop\"}\n\n";
+
 /* The lead delegates a self-contained task to a labelled subagent. */
 static const char sse_agentcall[] =
 "event: message_start\n"
@@ -859,7 +887,108 @@ send_webfetch(struct tls *conn, int port)
 	send_webfetch_path(conn, port, "web");
 }
 
+static int
+hex_lower(int ch)
+{
+	return ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'));
+}
+
 static void
+output_read_id(const char *req, char id[33])
+{
+	static const char marker[] = "\\\"id\\\":\\\"";
+	const char *p;
+	size_t i;
+
+	p = strstr(req, marker);
+	if (p == NULL)
+		errx(1, "OUTPUTREAD fixture missing Artifact id");
+	p += sizeof(marker) - 1;
+	for (i = 0; i < 32; i++) {
+		if (!hex_lower((unsigned char)p[i]))
+			errx(1, "OUTPUTREAD fixture malformed Artifact id");
+		id[i] = p[i];
+	}
+	id[32] = '\0';
+	if (p[32] != '\\' || p[33] != '"')
+		errx(1, "OUTPUTREAD fixture unterminated Artifact id");
+}
+
+/* Recover the opaque id from the preceding generated Artifact result, then
+ * ask the Coordinator-local output_read tool for an exact binary page. */
+static void
+send_output_read(struct tls *conn, const char *req)
+{
+	char id[33], sse[2048];
+	int n;
+
+	output_read_id(req, id);
+
+	n = snprintf(sse, sizeof(sse),
+	    "event: message_start\n"
+	    "data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+	    "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n"
+	    "event: content_block_start\n"
+	    "data: {\"type\":\"content_block_start\",\"index\":0,"
+	    "\"content_block\":{\"type\":\"tool_use\",\"id\":"
+	    "\"toolu_output_read\",\"name\":\"output_read\"}}\n\n"
+	    "event: content_block_delta\n"
+	    "data: {\"type\":\"content_block_delta\",\"index\":0,"
+	    "\"delta\":{\"type\":\"input_json_delta\","
+	    "\"partial_json\":\"{\\\"id\\\":\\\"%s\\\","
+	    "\\\"offset\\\":65536,\\\"limit\\\":4,"
+	    "\\\"encoding\\\":\\\"base64\\\"}\"}}\n\n"
+	    "event: content_block_stop\n"
+	    "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+	    "event: message_delta\n"
+	    "data: {\"type\":\"message_delta\",\"delta\":{"
+	    "\"stop_reason\":\"tool_use\"},\"usage\":{"
+	    "\"output_tokens\":12}}\n\n"
+	    "event: message_stop\n"
+	    "data: {\"type\":\"message_stop\"}\n\n", id);
+	if (n < 0 || (size_t)n >= sizeof(sse))
+		errx(1, "OUTPUTREAD fixture too large");
+	(void)tls_write_all(conn, body_ok, strlen(body_ok));
+	write_chunk(conn, sse, (size_t)n);
+	(void)tls_write_all(conn, "0\r\n\r\n", 5);
+}
+
+/* Exercise Coordinator defaults with id as the only supplied argument. */
+static void
+send_output_read_default(struct tls *conn, const char *req)
+{
+	char id[33], sse[2048];
+	int n;
+
+	output_read_id(req, id);
+	n = snprintf(sse, sizeof(sse),
+	    "event: message_start\n"
+	    "data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+	    "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n"
+	    "event: content_block_start\n"
+	    "data: {\"type\":\"content_block_start\",\"index\":0,"
+	    "\"content_block\":{\"type\":\"tool_use\",\"id\":"
+	    "\"toolu_output_read_default\",\"name\":\"output_read\"}}\n\n"
+	    "event: content_block_delta\n"
+	    "data: {\"type\":\"content_block_delta\",\"index\":0,"
+	    "\"delta\":{\"type\":\"input_json_delta\","
+	    "\"partial_json\":\"{\\\"id\\\":\\\"%s\\\"}\"}}\n\n"
+	    "event: content_block_stop\n"
+	    "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+	    "event: message_delta\n"
+	    "data: {\"type\":\"message_delta\",\"delta\":{"
+	    "\"stop_reason\":\"tool_use\"},\"usage\":{"
+	    "\"output_tokens\":12}}\n\n"
+	    "event: message_stop\n"
+	    "data: {\"type\":\"message_stop\"}\n\n", id);
+	if (n < 0 || (size_t)n >= sizeof(sse))
+		errx(1, "OUTPUTREADDEFAULT fixture too large");
+	(void)tls_write_all(conn, body_ok, strlen(body_ok));
+	write_chunk(conn, sse, (size_t)n);
+	(void)tls_write_all(conn, "0\r\n\r\n", 5);
+}
+
+static void
 send_wave_webfetch(struct tls *conn, int port, const char *req)
 {
 	if (strstr(req, "WAVE-PROMPT-A") != NULL)
@@ -952,6 +1081,51 @@ static const char sse_bgtool[] =
 "event: message_stop\n"
 "data: {\"type\":\"message_stop\"}\n\n";
 
+/* A short job completes after its launching Turn can commit.  A later
+ * retrying request keeps the Coordinator event loop alive long enough to
+ * exercise asynchronous current-spool retirement. */
+static const char sse_bgshort[] =
+"event: message_start\n"
+"data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+    "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n"
+"event: content_block_start\n"
+"data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_short\","
+    "\"name\":\"shell_bg\"}}\n\n"
+"event: content_block_delta\n"
+"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"input_json_delta\",\"partial_json\":"
+    "\"{\\\"command\\\":\\\"sleep 1; echo short-done\\\"}\"}}\n\n"
+"event: content_block_stop\n"
+"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+"event: message_delta\n"
+"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"tool_use\"},\"usage\":{\"output_tokens\":12}}\n\n"
+"event: message_stop\n"
+"data: {\"type\":\"message_stop\"}\n\n";
+
+/* Complete comfortably before the retrying next Turn so its current spool
+ * can be archived without a model-requested shell_output poll. */
+static const char sse_bgfast[] =
+"event: message_start\n"
+"data: {\"type\":\"message_start\",\"message\":{\"usage\":"
+    "{\"input_tokens\":9,\"output_tokens\":1}}}\n\n"
+"event: content_block_start\n"
+"data: {\"type\":\"content_block_start\",\"index\":0,"
+    "\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_fast\","
+    "\"name\":\"shell_bg\"}}\n\n"
+"event: content_block_delta\n"
+"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":"
+    "{\"type\":\"input_json_delta\",\"partial_json\":"
+    "\"{\\\"command\\\":\\\"echo fast-done\\\"}\"}}\n\n"
+"event: content_block_stop\n"
+"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n"
+"event: message_delta\n"
+"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":"
+    "\"tool_use\"},\"usage\":{\"output_tokens\":12}}\n\n"
+"event: message_stop\n"
+"data: {\"type\":\"message_stop\"}\n\n";
+
 /* Probe the exact id allocated by the preceding BGTOOL response.  If the
  * failed turn was rolled back, shell_output returns "no such job 1" and
  * that tool error appears in the following request. */
@@ -1578,6 +1752,16 @@ main(int argc, char *argv[])
 				write_chunk(conn, sse_tooluse,
 				    strlen(sse_tooluse));
 				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "ARTIFACTTOOL") == 0) {
+				(void)tls_write_all(conn, body_ok,
+				    strlen(body_ok));
+				write_chunk(conn, sse_artifact_tool,
+				    strlen(sse_artifact_tool));
+				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "OUTPUTREAD") == 0) {
+				send_output_read(conn, req);
+			} else if (strcmp(argv[i], "OUTPUTREADDEFAULT") == 0) {
+				send_output_read_default(conn, req);
 			} else if (strcmp(argv[i], "AGENTCALL") == 0) {
 				(void)tls_write_all(conn, body_ok,
 				    strlen(body_ok));
@@ -1923,6 +2107,18 @@ main(int argc, char *argv[])
 				write_chunk(conn, sse_bgtool,
 				    strlen(sse_bgtool));
 				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "BGSHORT") == 0) {
+				(void)tls_write_all(conn, body_ok,
+				    strlen(body_ok));
+				write_chunk(conn, sse_bgshort,
+				    strlen(sse_bgshort));
+				(void)tls_write_all(conn, "0\r\n\r\n", 5);
+			} else if (strcmp(argv[i], "BGFAST") == 0) {
+				(void)tls_write_all(conn, body_ok,
+				    strlen(body_ok));
+				write_chunk(conn, sse_bgfast,
+				    strlen(sse_bgfast));
+				(void)tls_write_all(conn, "0\r\n\r\n", 5);
 			} else if (strcmp(argv[i], "ERROR_BG") == 0) {
 				(void)tls_write_all(conn, body_ok,
 				    strlen(body_ok));
blob - 017c177fb52c67a49420620a94e387cd98208b87
blob + c41d37e01f962721f2c0d9941f3795cbc491b823
--- regress/web/tooldefs_test.c
+++ regress/web/tooldefs_test.c
@@ -27,6 +27,12 @@ main(void)
 
 	memset(&cf, 0, sizeof(cf));
 	defs = tooldefs(&cf, NULL, &n);
+	d = find(defs, n, "output_read");
+	CHECK(d != NULL && strstr(d->schema, "\"required\":[\"id\"]") != NULL);
+	CHECK(d != NULL && strstr(d->schema, "\"offset\"") != NULL &&
+	    strstr(d->schema, "\"limit\"") != NULL &&
+	    strstr(d->schema, "\"encoding\"") != NULL &&
+	    strstr(d->schema, "\"path\"") == NULL);
 	CHECK(find(defs, n, "web_search") == NULL);
 	CHECK(find(defs, n, "web_fetch") == NULL);
 	CHECK(find(defs, n, "http_request") == NULL);
blob - a0d582a0446f829a8eb7822ad01aaecd3936273e
blob + 700421220e2a3822ce1396669b4f4dce9a7609e1
--- scripts/live-check
+++ scripts/live-check
@@ -956,7 +956,7 @@ privileged()
 	    "root:_fugu:2555" ]
 	ok "installed front binary is root:_fugu mode 2555" $?
 
-	workers='fugu-api fugu-editor fugu-tool fugu-tty fugu-web'
+	workers='fugu-api fugu-artifact fugu-editor fugu-tool fugu-tty fugu-web'
 	rc=0
 	for worker in $workers; do
 		path=$prefix/libexec/fugu/$worker
@@ -965,7 +965,7 @@ privileged()
 		    "root:wheel:555" ] ||
 		    rc=1
 	done
-	ok "all five workers are root:wheel mode 0555" "$rc"
+	ok "all six workers are root:wheel mode 0555" "$rc"
 	[ -f "$prefix/man/man1/fugu.1" ] &&
 	    [ -f "$prefix/man/man5/fugu.conf.5" ] &&
 	    [ "$(stat -f '%Su:%Sg:%Lp' "$prefix/man/man1/fugu.1")" = \
blob - 3a5b3e5ba5f5dbdea6852e9508b54cb9e21709bb
blob + c805edb8d73ef9b945e5385a660518f1fa6fe68f
--- src/fugu/Makefile
+++ src/fugu/Makefile
@@ -7,8 +7,8 @@ SRCS=	main.c coord.c priv.c conf.c parse.y \
 	generation.c compaction.c msg.c \
 	provider_state.c \
 	json.c utf8.c artifact_result.c artifact_page.c artifact_lock.c \
-	model_window.c effort.c \
-	buf.c imsgev.c log.c xmalloc.c
+	artifact_pipe.c artifact_scope.c model_window.c effort.c buf.c \
+	imsgev.c log.c xmalloc.c
 MAN=	fugu.1 fugu.conf.5
 MANDIR=	${PREFIX}/man/man
 
blob - 7fad8eb0c201bda22f5fd69cfd1f0bd99e5a4c4c
blob + 627654da188367f06d91eed00db8e43157e03c0c
--- src/fugu/artifact_capture.c
+++ src/fugu/artifact_capture.c
@@ -100,6 +100,19 @@ artifact_capture_free(struct artifact_capture *capture
 	memset(capture, 0, sizeof(*capture));
 }
 
+size_t
+artifact_capture_read_limit(const struct artifact_capture *capture, size_t want)
+{
+	uint64_t left;
+
+	if (capture->observed >= (uint64_t)ARTIFACT_RAW_MAX + 1)
+		return (0);
+	left = (uint64_t)ARTIFACT_RAW_MAX + 1 - capture->observed;
+	if ((uint64_t)want > left)
+		want = (size_t)left;
+	return (want);
+}
+
 int
 artifact_capture_add(struct artifact_capture *capture, const void *data,
     size_t len)
blob - 3759b94c1981bf6a95b6862330ebfc0e7a7fe3a2
blob + 6d043e6f5cf6d3c9eec713aeeca0feef479f5972
--- src/fugu/artifact_capture.h
+++ src/fugu/artifact_capture.h
@@ -34,6 +34,8 @@ struct artifact_capture {
 void artifact_capture_init(struct artifact_capture *);
 void artifact_capture_reset(struct artifact_capture *);
 void artifact_capture_free(struct artifact_capture *);
+/* Bound one producer read to the first byte that proves a hard-cap crossing. */
+size_t artifact_capture_read_limit(const struct artifact_capture *, size_t);
 /* Adds observed bytes, retaining at most 16 MiB.  Returns 1 on cap crossing. */
 int artifact_capture_add(struct artifact_capture *, const void *, size_t);
 void artifact_capture_view(const struct artifact_capture *,
blob - c32217e2d36f75fa53dfe11fdcd20543936bc89a
blob + 8be7494b9a5dec8d0f37df4612a802afac4bc1bf
--- src/fugu/artifact_lock.c
+++ src/fugu/artifact_lock.c
@@ -7,6 +7,7 @@
  */
 
 #include <sys/types.h>
+#include <sys/file.h>
 #include <sys/stat.h>
 
 #include <fcntl.h>
@@ -37,7 +38,8 @@ artifact_lifetime_lock_valid(int fd, const char *root,
 	    (flags & O_ACCMODE) != O_RDWR ||
 	    (flags & (O_APPEND | O_NONBLOCK)) != 0 ||
 	    (fdflags = fcntl(fd, F_GETFD)) == -1 ||
-	    (fdflags & FD_CLOEXEC) == 0)
+	    (fdflags & FD_CLOEXEC) == 0 ||
+	    flock(fd, LOCK_EX | LOCK_NB) == -1)
 		return (0);
 	return (1);
 }
blob - cba1d4dc9abc56081db28756f3f9181b58d24424
blob + 96c31c3b305d3ce2dcb11879f7b2effe2f43ba73
--- src/fugu/artifact_page.c
+++ src/fugu/artifact_page.c
@@ -171,14 +171,14 @@ artifact_page_build(struct buf *out, const char *id, u
 	if (encoding == ARTIFACT_PAGE_TEXT) {
 		while (used < len) {
 			unit = escaped_unit(raw + used, len - used, &nraw);
-			if (unit == 0) {
-				*retry_base64 = 1;
-				return (retry_result(out, id, offset));
+			if (unit == 0 ||
+			    (unit == SIZE_MAX && offset + len == total)) {
+				if (used == 0) {
+					*retry_base64 = 1;
+					return (retry_result(out, id, offset));
+				}
+				break;
 			}
-			if (unit == SIZE_MAX && offset + len == total) {
-				*retry_base64 = 1;
-				return (retry_result(out, id, offset));
-			}
 			if (unit == SIZE_MAX || unit >
 			    ARTIFACT_PAGE_DATA_ENCODED_MAX - enc)
 				break;
blob - /dev/null
blob + 123933b8353f2b99060d6a3f071750b22d0ad4c5 (mode 644)
--- /dev/null
+++ src/fugu/artifact_pipe.c
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <fcntl.h>
+#include <unistd.h>
+
+#include "artifact_pipe.h"
+
+int
+artifact_producer_endpoint_valid(int fd)
+{
+	struct stat st;
+	int flags, fdflags;
+
+	/* OpenBSD's bidirectional pipe extension reports both pipe(2)
+	 * endpoints as exactly O_RDWR.  Protocol assigns this endpoint only
+	 * the producer direction; it must also remain a blocking FIFO and
+	 * close across exec. */
+	return (fd >= 0 && fstat(fd, &st) == 0 && S_ISFIFO(st.st_mode) &&
+	    (flags = fcntl(fd, F_GETFL)) != -1 && flags == O_RDWR &&
+	    (fdflags = fcntl(fd, F_GETFD)) != -1 &&
+	    (fdflags & FD_CLOEXEC) != 0);
+}
blob - /dev/null
blob + ffeec137dd2076e7b78851f5e643a924b9c2d11f (mode 644)
--- /dev/null
+++ src/fugu/artifact_pipe.h
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ */
+
+#ifndef ARTIFACT_PIPE_H
+#define ARTIFACT_PIPE_H
+
+/* Exact validation for the producer endpoint brokered by Artifact. */
+int artifact_producer_endpoint_valid(int);
+
+#endif /* ARTIFACT_PIPE_H */
blob - 444af14d763fae39f59299bfcb868bf57892a4d5
blob + c121936fd24e3eb1237528dc5cba5d5fded4e091
--- src/fugu/coord.c
+++ src/fugu/coord.c
@@ -15,6 +15,7 @@
  */
 
 #include <sys/types.h>
+#include <sys/queue.h>
 #include <sys/stat.h>
 #include <sys/wait.h>
 
@@ -24,6 +25,7 @@
 #include <fcntl.h>
 #include <glob.h>
 #include <imsg.h>
+#include <limits.h>
 #include <signal.h>
 #include <stdarg.h>
 #include <stdint.h>
@@ -64,6 +66,8 @@
 #include "artifact_result.h"
 #include "artifact_page.h"
 #include "artifact_lock.h"
+#include "artifact_pipe.h"
+#include "artifact_scope.h"
 
 #define TURN_ITER_MAX	50		/* behavior.md section 5 */
 #define MAX_TOOL_CALLS	GENERATION_CALL_MAX
@@ -101,18 +105,36 @@ struct tool_call {
 struct coord;
 
 struct artifact_live {
-	struct artifact_live *next;
+	LIST_ENTRY(artifact_live) entry;
 	uint32_t correlation;
+	unsigned int refs;
+	int removed;
 	int background;
 	int adopted;
+	int job_id;
+	int job_failed;
+	int failed_discarded;
 	int turn_owned;
 	int discard_when_terminal;
+	int release_when_terminal;
+	int rotation_done;
+	int rotation_adopted;
+	int rotation_detach;
+	int drop_seen;
+	int drop_pending;
+	int cancel_hold;
+	int cancel_retire;
+	int cancel_pending;
+	uint32_t frozen_correlation;
+	char frozen_id[FUGU_ARTIFACT_ID_BUFSZ];
 	int stream_done;
 	int terminal_done;
 	struct artifact_stream stream;
 	struct artifact_terminal terminal;
 };
 
+LIST_HEAD(artifact_live_head, artifact_live);
+
 /* One on-demand fugu-tool -r instance.  Its conversation and local tool
  * exchanges stay inside that process; only the final report is retained. */
 struct subagent {
@@ -261,9 +283,12 @@ struct coord {
 	uint32_t		 artifact_ctl_expected;
 	int			 artifact_ctl_done;
 	uint32_t		 artifact_call_id;
+	int			 artifact_call_job_id;
+	int			 artifact_call_shell_bg;
+	int			 artifact_call_shell_output;
 	struct artifact_live	*artifact_call;
 	struct artifact_live	*artifact_wait;
-	struct artifact_live	*artifacts;
+	struct artifact_live_head artifacts;
 	struct buf		 artifact_read_data;
 	struct artifact_read_result artifact_read_result;
 	struct artifact_read_req	 artifact_read_req;
@@ -313,6 +338,22 @@ static const char *validate_epoch_projection(struct co
     enum turn_tools_mode, int);
 static enum turn_io compact_projection(struct coord *, int, int,
     enum turn_tools_mode, struct generation_usage *, char *, size_t);
+static void artifact_background_failure(struct coord *,
+    struct artifact_live *);
+static void artifact_rotation_event(struct coord *, uint32_t,
+    const u_char *, size_t);
+static void artifact_drop_event(struct coord *, uint32_t,
+    const u_char *, size_t);
+static void artifact_cancel_ack_event(struct coord *, uint32_t,
+    const u_char *, size_t);
+static void artifact_cancelled_rotation(struct coord *,
+    struct artifact_live *);
+static void artifact_discard(struct coord *, const struct artifact_live *);
+static void artifact_job_control(struct coord *, uint32_t,
+    const struct artifact_live *);
+static void artifact_cancel_retire(struct coord *, int);
+static int artifact_cancelled_tool_result_valid(struct coord *,
+    const struct artifact_live *, const struct tool_result *);
 
 static struct subagent *
 agent_by_api_id(struct coord *c, uint32_t id)
@@ -395,7 +436,7 @@ artifact_live_by_correlation(struct coord *c, uint32_t
 {
 	struct artifact_live *live;
 
-	for (live = c->artifacts; live != NULL; live = live->next)
+	LIST_FOREACH(live, &c->artifacts, entry)
 		if (live->correlation == correlation)
 			return (live);
 	return (NULL);
@@ -408,7 +449,7 @@ artifact_live_by_id(struct coord *c, const char *id)
 
 	if (!artifact_id_wire_valid(id))
 		return (NULL);
-	for (live = c->artifacts; live != NULL; live = live->next)
+	LIST_FOREACH(live, &c->artifacts, entry)
 		if (live->stream_done &&
 		    live->stream.status == ARTIFACT_STREAM_AVAILABLE &&
 		    strcmp(live->stream.id, id) == 0)
@@ -416,6 +457,48 @@ artifact_live_by_id(struct coord *c, const char *id)
 	return (NULL);
 }
 
+static int
+artifact_result_names_call_stream(const struct tool_result *result,
+    const struct artifact_live *live)
+{
+	static const char zero[FUGU_ARTIFACT_ID_BUFSZ];
+
+	if (live == NULL || !live->stream_done)
+		return (0);
+	if (live->stream.status == ARTIFACT_STREAM_AVAILABLE)
+		return (artifact_id_wire_valid(result->artifact_id) &&
+		    strcmp(result->artifact_id, live->stream.id) == 0);
+	if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE)
+		return (memcmp(result->artifact_id, zero, sizeof(zero)) == 0);
+	return (0);
+}
+
+static int
+artifact_result_fields_zero(const struct tool_result *result)
+{
+	static const char zero[FUGU_ARTIFACT_ID_BUFSZ];
+
+	return (!result->artifact && !result->keep_artifact &&
+	    !result->artifact_cap_reached && !result->spool_frozen &&
+	    !result->spool_adopted && !result->spool_detach &&
+	    result->background_job_id == 0 &&
+	    result->termination == ARTIFACT_TERM_EXIT &&
+	    result->observed_bytes == 0 && result->retained_bytes == 0 &&
+	    memcmp(result->artifact_id, zero, sizeof(zero)) == 0);
+}
+
+static int
+artifact_result_matches_rotation(const struct tool_result *result,
+    const struct artifact_live *live)
+{
+	return (live != NULL && live->rotation_done && result->spool_frozen &&
+	    result->spool_adopted == live->rotation_adopted &&
+	    result->spool_detach == live->rotation_detach &&
+	    result->background_job_id == live->job_id &&
+	    memcmp(result->artifact_id, live->frozen_id,
+	    sizeof(live->frozen_id)) == 0);
+}
+
 static struct artifact_live *
 artifact_live_new(struct coord *c, uint32_t correlation, int background)
 {
@@ -428,26 +511,70 @@ artifact_live_new(struct coord *c, uint32_t correlatio
 	live->correlation = correlation;
 	live->background = background;
 	live->turn_owned = 1;
-	live->next = c->artifacts;
-	c->artifacts = live;
+	LIST_INSERT_HEAD(&c->artifacts, live, entry);
 	return (live);
 }
 
 static void
+artifact_live_pin(struct artifact_live *live)
+{
+	if (live == NULL || live->refs == UINT_MAX)
+		fatalx("invalid Artifact live pin");
+	live->refs++;
+}
+
+static void
+artifact_live_unpin(struct artifact_live *live)
+{
+	if (live == NULL || live->refs == 0)
+		fatalx("invalid Artifact live unpin");
+	live->refs--;
+	if (live->removed && live->refs == 0)
+		free(live);
+}
+
+static void
+artifact_live_remove_now(struct coord *c, struct artifact_live *live)
+{
+	struct artifact_live *scan;
+
+	if (live == NULL || live->removed)
+		fatalx("duplicate Artifact live removal");
+	LIST_FOREACH(scan, &c->artifacts, entry)
+		if (scan == live)
+			break;
+	if (scan == NULL)
+		fatalx("lost Artifact live state");
+	LIST_FOREACH(scan, &c->artifacts, entry) {
+		if (scan == live || scan->frozen_correlation !=
+		    live->correlation)
+			continue;
+		scan->frozen_correlation = 0;
+	}
+	LIST_REMOVE(live, entry);
+	live->removed = 1;
+	if (c->artifact_call == live)
+		c->artifact_call = NULL;
+	if (c->artifact_wait == live)
+		c->artifact_wait = NULL;
+	if (live->refs == 0)
+		free(live);
+}
+
+static void
 artifact_live_remove(struct coord *c, struct artifact_live *live)
 {
-	struct artifact_live **link;
-
-	for (link = &c->artifacts; *link != NULL; link = &(*link)->next) {
-		if (*link != live)
-			continue;
-		*link = live->next;
+	if (live == NULL || live->removed)
+		fatalx("duplicate Artifact live removal");
+	if (live->cancel_hold) {
+		live->cancel_retire = 1;
 		if (c->artifact_call == live)
 			c->artifact_call = NULL;
-		free(live);
+		if (c->artifact_wait == live)
+			c->artifact_wait = NULL;
 		return;
 	}
-	fatalx("lost Artifact live state");
+	artifact_live_remove_now(c, live);
 }
 
 static uint32_t
@@ -554,7 +681,7 @@ turn_artifacts_commit(void *arg)
 	struct artifact_live *live;
 
 	artifact_turn_ctl(c, FUGU_IMSG_ARTIFACT_TURN_COMMIT);
-	for (live = c->artifacts; live != NULL; live = live->next)
+	LIST_FOREACH(live, &c->artifacts, entry)
 		if (live->turn_owned)
 			live->turn_owned = 0;
 	c->nturn_artifacts = 0;
@@ -567,8 +694,7 @@ turn_artifacts_abort(void *arg)
 	struct artifact_live *live, *next;
 
 	artifact_turn_ctl(c, FUGU_IMSG_ARTIFACT_TURN_ABORT);
-	for (live = c->artifacts; live != NULL; live = next) {
-		next = live->next;
+	LIST_FOREACH_SAFE(live, &c->artifacts, entry, next) {
 		if (live->turn_owned)
 			artifact_live_remove(c, live);
 	}
@@ -735,6 +861,96 @@ libexec_dir(void)
 }
 
 static int
+path_contains(const char *parent, const char *child)
+{
+	size_t len;
+
+	len = strlen(parent);
+	if (len == 1 && parent[0] == '/')
+		return (child[0] == '/');
+	return (strncmp(parent, child, len) == 0 &&
+	    (child[len] == '\0' || child[len] == '/'));
+}
+
+static int
+paths_overlap(const char *a, const char *b)
+{
+	return (path_contains(a, b) || path_contains(b, a));
+}
+
+/*
+ * The persistent Lead Tool unveils the physical project tree plus these
+ * fixed runtime roots.  Managed Artifact bytes must be outside every one of
+ * those trees: a broader unveil cannot be repaired after a Tool starts, and
+ * the Tool Role must never receive raw-store path authority.
+ */
+static void
+artifact_root_scope_check(const struct coord *c, const char *root)
+{
+	static const char *const fixed[] = {
+		"/dev/null", "/usr", "/bin", "/sbin", "/lib", "/libexec"
+	};
+	static const char *const network[] = {
+		"/etc/resolv.conf", "/etc/hosts", "/etc/ssl"
+	};
+	char path[PATH_MAX];
+	size_t i;
+
+	if (realpath(".", path) == NULL)
+		fatal("realpath project directory");
+	if (paths_overlap(root, path))
+		fatalx("managed Artifact storage overlaps Tool filesystem authority");
+	for (i = 0; i < sizeof(fixed) / sizeof(fixed[0]); i++) {
+		if (realpath(fixed[i], path) == NULL &&
+		    (errno != ENOENT || strlcpy(path, fixed[i], sizeof(path)) >=
+		    sizeof(path)))
+			fatal("resolve Tool runtime path %s", fixed[i]);
+		if (paths_overlap(root, path))
+			fatalx("managed Artifact storage overlaps Tool filesystem authority");
+	}
+	if (!c->conf->allow_subprocess_net)
+		return;
+	for (i = 0; i < sizeof(network) / sizeof(network[0]); i++) {
+		if (realpath(network[i], path) == NULL &&
+		    (errno != ENOENT || strlcpy(path, network[i], sizeof(path)) >=
+		    sizeof(path)))
+			fatal("resolve Tool network path %s", network[i]);
+		if (paths_overlap(root, path))
+			fatalx("managed Artifact storage overlaps Tool filesystem authority");
+	}
+}
+
+/* Resolve HOME and ~/.fugu before any worker, Journal, or Provider action.
+ * The preliminary physical-HOME check catches fixed-root HOME values before
+ * creating ~/.fugu; the second catches a pre-existing ~/.fugu symlink. */
+static void
+artifact_root_prepare(struct coord *c)
+{
+	struct stat st;
+	const char *home;
+	char fugu[PATH_MAX], physical[PATH_MAX], root[PATH_MAX];
+
+	home = getenv("HOME");
+	if (home == NULL || home[0] != '/' || realpath(home, physical) == NULL ||
+	    (size_t)snprintf(root, sizeof(root), "%s/.fugu/artifacts",
+	    physical) >= sizeof(root))
+		fatalx("cannot use managed Artifact storage");
+	artifact_root_scope_check(c, root);
+	if ((size_t)snprintf(fugu, sizeof(fugu), "%s/.fugu", home) >=
+	    sizeof(fugu) || (mkdir(fugu, 0700) == -1 && errno != EEXIST) ||
+	    realpath(fugu, physical) == NULL ||
+	    (size_t)snprintf(root, sizeof(root), "%s/artifacts", physical) >=
+	    sizeof(root))
+		fatalx("cannot use managed Artifact storage");
+	artifact_root_scope_check(c, root);
+	if (stat(physical, &st) == -1 || !S_ISDIR(st.st_mode) ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0700 ||
+	    strlcpy(c->artifact_root, root, sizeof(c->artifact_root)) >=
+	    sizeof(c->artifact_root))
+		fatalx("cannot use managed Artifact storage");
+}
+
+static int
 protect_has(const struct buf *b, const char *path)
 {
 	size_t off, len;
@@ -1693,17 +1909,51 @@ tool_event(struct coord *c, struct imsg *imsg)
 		p = payload.data;
 		len = payload.len;
 		switch (imsg_get_type(imsg)) {
+		case FUGU_IMSG_TOOL_ARTIFACT_ROTATE:
+			artifact_rotation_event(c, imsg_get_id(imsg), p, len);
+			return;
 		case FUGU_IMSG_TOOL_OUT:
 		case FUGU_IMSG_TOOL_DIFF:
 			if (p == NULL || len == 0)
 				fatalx("bad cancelled Tool chunk");
 			return;
 		case FUGU_IMSG_TOOL_RESULT:
-			if (p == NULL || len != sizeof(struct tool_result))
+			{
+			struct artifact_live *call_live = c->artifact_call;
+			struct tool_result r;
+			int job_id;
+
+			if (call_live == NULL && c->artifact_call_id != 0)
+				call_live = artifact_live_by_correlation(c,
+				    c->artifact_call_id);
+			job_id = call_live != NULL ? call_live->job_id : 0;
+			if (p == NULL || len != sizeof(r))
 				fatalx("bad cancelled Tool terminal");
+			memcpy(&r, p, sizeof(r));
+			if ((r.is_error != 0 && r.is_error != 1) ||
+			    (r.artifact != 0 && r.artifact != 1) ||
+			    (r.keep_artifact != 0 && r.keep_artifact != 1) ||
+			    (r.artifact_cap_reached != 0 &&
+			    r.artifact_cap_reached != 1) ||
+			    (r.spool_frozen != 0 && r.spool_frozen != 1) ||
+			    (r.spool_adopted != 0 && r.spool_adopted != 1) ||
+			    (r.spool_detach != 0 && r.spool_detach != 1) ||
+			    (r.spool_detach && !r.spool_adopted) ||
+			    r.background_job_id < 0 ||
+			    !artifact_cancelled_tool_result_valid(c, call_live, &r))
+				fatalx("mismatched cancelled Tool terminal");
+			artifact_cancelled_rotation(c, call_live);
 			c->tool_cancel_draining = 0;
 			c->tool_cancel_id = 0;
+			c->artifact_call = NULL;
+			c->artifact_call_id = 0;
+			c->artifact_call_job_id = 0;
+			c->artifact_call_shell_bg = 0;
+			c->artifact_call_shell_output = 0;
+			if (job_id > 0)
+				artifact_cancel_retire(c, job_id);
 			return;
+			}
 		default:
 			fatalx("bad cancelled Tool frame");
 		}
@@ -1718,6 +1968,9 @@ tool_event(struct coord *c, struct imsg *imsg)
 	len = payload.len;
 
 	switch (imsg_get_type(imsg)) {
+	case FUGU_IMSG_TOOL_ARTIFACT_ROTATE:
+		artifact_rotation_event(c, imsg_get_id(imsg), p, len);
+		break;
 	case FUGU_IMSG_TOOL_OUT:
 		{
 			size_t max = c->artifact_call_id != 0 ?
@@ -1749,11 +2002,24 @@ tool_event(struct coord *c, struct imsg *imsg)
 			    (r.keep_artifact != 0 && r.keep_artifact != 1) ||
 			    (r.artifact_cap_reached != 0 &&
 			    r.artifact_cap_reached != 1) ||
+			    (r.spool_frozen != 0 && r.spool_frozen != 1) ||
 			    (r.spool_adopted != 0 && r.spool_adopted != 1) ||
 			    (r.spool_detach != 0 && r.spool_detach != 1) ||
 			    (r.spool_detach && !r.spool_adopted) ||
+			    (!c->artifact_call_shell_output && r.spool_frozen) ||
+			    (c->artifact_call_shell_output &&
+			    (c->artifact_call == NULL || r.spool_frozen !=
+			    c->artifact_call->rotation_done ||
+			    (r.spool_frozen &&
+			    !artifact_result_matches_rotation(&r,
+			    c->artifact_call)) ||
+			    (!r.spool_frozen && (r.spool_adopted ||
+			    r.spool_detach ||
+			    !artifact_result_names_call_stream(&r,
+			    c->artifact_call))))) ||
+			    r.background_job_id < 0 ||
 			    (c->artifact_call_id == 0 &&
-			    (r.artifact || r.spool_adopted)))
+			    !artifact_result_fields_zero(&r)))
 				c->turn_data_exceeded = 1;
 			else {
 				c->tool_is_error = r.is_error;
@@ -1793,19 +2059,6 @@ tool_ctl_event(struct coord *c, struct imsg *imsg)
 }
 
 static int
-artifact_pipe_write_fd(int fd)
-{
-	struct stat st;
-	int flags, fdflags;
-
-	return (fd >= 0 && fstat(fd, &st) == 0 && S_ISFIFO(st.st_mode) &&
-	    (flags = fcntl(fd, F_GETFL)) != -1 &&
-	    flags == O_WRONLY &&
-	    (fdflags = fcntl(fd, F_GETFD)) != -1 &&
-	    (fdflags & FD_CLOEXEC) != 0);
-}
-
-static int
 artifact_id_zero(const char id[FUGU_ARTIFACT_ID_BUFSZ])
 {
 	static const char zero[FUGU_ARTIFACT_ID_BUFSZ];
@@ -1813,7 +2066,276 @@ artifact_id_zero(const char id[FUGU_ARTIFACT_ID_BUFSZ]
 	return (memcmp(id, zero, sizeof(zero)) == 0);
 }
 
+static struct artifact_live *
+artifact_unavailable_by_job(struct coord *c, int job_id,
+    const struct artifact_live *exclude)
+{
+	struct artifact_live *live, *found = NULL;
+
+	LIST_FOREACH(live, &c->artifacts, entry) {
+		if (live == exclude || !live->background || !live->adopted ||
+		    live->job_id != job_id || !live->stream_done ||
+		    live->stream.status != ARTIFACT_STREAM_UNAVAILABLE)
+			continue;
+		if (found != NULL)
+			fatalx("ambiguous unavailable Artifact spool");
+		found = live;
+	}
+	return (found);
+}
+
+static struct artifact_live *
+artifact_rotation_frozen(struct coord *c, const struct artifact_live *live)
+{
+	struct artifact_live *frozen;
+
+	if (!live->rotation_done || live->frozen_correlation == 0 ||
+	    (frozen = artifact_live_by_correlation(c,
+	    live->frozen_correlation)) == NULL)
+		return (NULL);
+	if (frozen == live || !frozen->background || !frozen->adopted ||
+	    frozen->job_id != live->job_id || !frozen->stream_done ||
+	    (artifact_id_zero(live->frozen_id) &&
+	    frozen->stream.status != ARTIFACT_STREAM_UNAVAILABLE) ||
+	    (!artifact_id_zero(live->frozen_id) &&
+	    (frozen->stream.status != ARTIFACT_STREAM_AVAILABLE ||
+	    strcmp(frozen->stream.id, live->frozen_id) != 0)))
+		fatalx("lost frozen Artifact spool ownership");
+	return (frozen);
+}
+
+static int
+artifact_live_is_current(struct coord *c, const struct artifact_live *target)
+{
+	struct artifact_live *live;
+
+	LIST_FOREACH(live, &c->artifacts, entry)
+		if (live != target && live->background && live->adopted &&
+		    live->job_id == target->job_id &&
+		    live->frozen_correlation == target->correlation)
+			return (0);
+	return (1);
+}
+
+static int
+artifact_live_referenced_by_active(struct coord *c,
+    const struct artifact_live *target)
+{
+	struct artifact_live *live;
+
+	LIST_FOREACH(live, &c->artifacts, entry)
+		if (live != target && live->frozen_correlation ==
+		    target->correlation && (live->refs != 0 ||
+		    live == c->artifact_call || live == c->artifact_wait))
+			return (1);
+	return (0);
+}
+
 static void
+artifact_cancel_retire(struct coord *c, int job_id)
+{
+	struct artifact_live *live, *next;
+
+	LIST_FOREACH_SAFE(live, &c->artifacts, entry, next) {
+		if (live->job_id != job_id || live->cancel_hold ||
+		    !live->cancel_retire || live->refs != 0 ||
+		    live == c->artifact_call || live == c->artifact_wait ||
+		    artifact_live_referenced_by_active(c, live))
+			continue;
+		artifact_live_remove_now(c, live);
+	}
+}
+
+static void
+artifact_drop_ack(struct coord *c, const struct artifact_live *live)
+{
+	struct artifact_spool_drop drop;
+
+	memset(&drop, 0, sizeof(drop));
+	drop.job_id = (uint32_t)live->job_id;
+	if (live->stream.status == ARTIFACT_STREAM_AVAILABLE)
+		strlcpy(drop.artifact_id, live->stream.id,
+		    sizeof(drop.artifact_id));
+	imsgev_send(&c->iev[ROLE_TOOL], FUGU_IMSG_TOOL_ARTIFACT_DROP_ACK,
+	    live->correlation, &drop, sizeof(drop));
+}
+
+/*
+ * A spool swap changes ownership before the ordinary Tool result is
+ * discardable.  Record that change as its own transaction frame so Turn
+ * cancellation cannot roll a committed job's replacement back into the
+ * aborted Turn.
+ */
+static void
+artifact_rotation_event(struct coord *c, uint32_t id, const u_char *p,
+    size_t len)
+{
+	struct artifact_spool_rotate rotate;
+	struct artifact_live *live, *frozen;
+	struct artifact_record record;
+
+	if (id == 0 || id != c->artifact_call_id || p == NULL ||
+	    len != sizeof(rotate) ||
+	    (live = c->artifact_call) == NULL || live->removed ||
+	    live->correlation != id || !live->background ||
+	    !live->stream_done || live->rotation_done ||
+	    c->artifact_call_job_id <= 0)
+		fatalx("bad Artifact spool rotation");
+	memcpy(&rotate, p, sizeof(rotate));
+	if (rotate.job_id == 0 || rotate.job_id > INT_MAX ||
+	    rotate.job_id != (uint32_t)c->artifact_call_job_id ||
+	    rotate.replacement_adopted > 1 || rotate.detach > 1 ||
+	    (!rotate.replacement_adopted &&
+	    (rotate.detach || !artifact_id_zero(rotate.replacement_id))))
+		fatalx("invalid Artifact spool rotation state");
+	if (rotate.replacement_adopted &&
+	    ((live->stream.status == ARTIFACT_STREAM_AVAILABLE &&
+	    (!artifact_id_wire_valid(rotate.replacement_id) ||
+	    strcmp(rotate.replacement_id, live->stream.id) != 0)) ||
+	    (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    !artifact_id_zero(rotate.replacement_id))))
+		fatalx("mismatched Artifact spool replacement");
+
+	frozen = NULL;
+	if (artifact_id_wire_valid(rotate.frozen_id)) {
+		frozen = artifact_live_by_id(c, rotate.frozen_id);
+		if (frozen == NULL || frozen == live || !frozen->background ||
+		    !frozen->adopted || frozen->job_id != (int)rotate.job_id ||
+		    frozen->drop_seen ||
+		    (rotate.replacement_adopted &&
+		    rotate.detach != (uint32_t)!frozen->turn_owned))
+			fatalx("mismatched frozen Artifact spool");
+	} else if (!artifact_id_zero(rotate.frozen_id))
+		fatalx("malformed frozen Artifact spool id");
+	else {
+		/* An unavailable old reservation has no wire id, but its retained
+		 * live record still proves job and Turn ownership. */
+		frozen = artifact_unavailable_by_job(c, (int)rotate.job_id,
+		    live);
+		if (frozen == NULL || frozen->drop_seen ||
+		    (rotate.replacement_adopted &&
+		    rotate.detach != (uint32_t)!frozen->turn_owned))
+			fatalx("missing unavailable Artifact spool ownership");
+	}
+	if (!artifact_live_is_current(c, frozen))
+		fatalx("historical Artifact spool cannot rotate again");
+
+	live->job_id = (int)rotate.job_id;
+	live->rotation_done = 1;
+	live->rotation_adopted = (int)rotate.replacement_adopted;
+	live->rotation_detach = (int)rotate.detach;
+	live->frozen_correlation = frozen->correlation;
+	memcpy(live->frozen_id, rotate.frozen_id, sizeof(live->frozen_id));
+	if (live->rotation_adopted)
+		live->adopted = 1;
+	if (live->rotation_detach) {
+		live->turn_owned = 0;
+		if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) {
+			memset(&record, 0, sizeof(record));
+			strlcpy(record.id, live->stream.id, sizeof(record.id));
+			imsgev_send(&c->iev[ROLE_ARTIFACT],
+			    FUGU_IMSG_ARTIFACT_DETACH, live->correlation,
+			    &record, sizeof(record));
+		}
+	}
+	if (live->rotation_adopted && live->terminal_done &&
+	    live->terminal.status == ARTIFACT_STORE_ERROR)
+		artifact_background_failure(c, live);
+}
+
+/* A completed, unpolled job asks to retire only its current spool.  Available
+ * spools wait for the store terminal before ACK; unavailable reservations have
+ * no Artifact-side terminal and can be acknowledged immediately. */
+static void
+artifact_drop_event(struct coord *c, uint32_t id, const u_char *p, size_t len)
+{
+	struct artifact_spool_drop drop;
+	struct artifact_live *live;
+
+	if (id == 0 || p == NULL || len != sizeof(drop) ||
+	    (live = artifact_live_by_correlation(c, id)) == NULL ||
+	    live->removed || !live->background || !live->adopted ||
+	    !live->stream_done || (live->job_failed && !live->cancel_hold) ||
+	    live->drop_seen ||
+	    !artifact_live_is_current(c, live))
+		fatalx("bad Artifact spool retirement");
+	memcpy(&drop, p, sizeof(drop));
+	if (drop.job_id == 0 || drop.job_id > INT_MAX ||
+	    live->job_id != (int)drop.job_id ||
+	    (live->stream.status == ARTIFACT_STREAM_AVAILABLE &&
+	    (!artifact_id_wire_valid(drop.artifact_id) ||
+	    strcmp(drop.artifact_id, live->stream.id) != 0)) ||
+	    (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
+	    !artifact_id_zero(drop.artifact_id)))
+		fatalx("mismatched Artifact spool retirement");
+	live->drop_seen = 1;
+	if (live->job_failed) {
+		if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) {
+			artifact_live_remove(c, live);
+			return;
+		}
+		if (!live->terminal_done) {
+			live->drop_pending = 1;
+			live->discard_when_terminal = 1;
+			return;
+		}
+		if (!live->failed_discarded) {
+			artifact_discard(c, live);
+			live->failed_discarded = 1;
+		}
+		artifact_live_remove(c, live);
+		return;
+	}
+	if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) {
+		artifact_drop_ack(c, live);
+		artifact_live_remove(c, live);
+		return;
+	}
+	if (!live->terminal_done) {
+		live->drop_pending = 1;
+		live->release_when_terminal = 0;
+		return;
+	}
+	if (live->terminal.status == ARTIFACT_STORE_ERROR) {
+		artifact_background_failure(c, live);
+		return;
+	}
+	artifact_discard(c, live);
+	artifact_drop_ack(c, live);
+	artifact_live_remove(c, live);
+}
+
+static void
+artifact_cancel_ack_event(struct coord *c, uint32_t id, const u_char *p,
+    size_t len)
+{
+	struct artifact_job_cancel_ack ack;
+	struct artifact_live *live, *marker = NULL;
+
+	if (id == 0 || id > INT_MAX || p == NULL || len != sizeof(ack))
+		fatalx("bad Artifact cancellation acknowledgement");
+	memcpy(&ack, p, sizeof(ack));
+	if (ack.job_id != id || !artifact_id_wire_valid(ack.artifact_id))
+		fatalx("invalid Artifact cancellation acknowledgement");
+	LIST_FOREACH(live, &c->artifacts, entry) {
+		if (!live->cancel_pending || live->job_id != (int)id)
+			continue;
+		if (marker != NULL)
+			fatalx("ambiguous Artifact cancellation acknowledgement");
+		marker = live;
+	}
+	if (marker == NULL || marker->job_id != (int)id ||
+	    marker->stream.status != ARTIFACT_STREAM_AVAILABLE ||
+	    strcmp(marker->stream.id, ack.artifact_id) != 0)
+		fatalx("stale Artifact cancellation acknowledgement");
+	marker->cancel_pending = 0;
+	LIST_FOREACH(live, &c->artifacts, entry)
+		if (live->job_id == (int)id)
+			live->cancel_hold = 0;
+	artifact_cancel_retire(c, (int)id);
+}
+
+static void
 artifact_event(struct coord *c, struct imsg *imsg)
 {
 	struct imsgev_payload payload;
@@ -1869,7 +2391,7 @@ artifact_event(struct coord *c, struct imsg *imsg)
 		    stream.status != ARTIFACT_STREAM_UNAVAILABLE) ||
 		    (stream.status == ARTIFACT_STREAM_AVAILABLE &&
 		    (!artifact_id_wire_valid(stream.id) ||
-		    !artifact_pipe_write_fd(fd))) ||
+		    !artifact_producer_endpoint_valid(fd))) ||
 		    (stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
 		    (fd != -1 || !artifact_id_zero(stream.id)))) {
 			if (fd != -1)
@@ -1898,7 +2420,7 @@ artifact_event(struct coord *c, struct imsg *imsg)
 	}
 	case FUGU_IMSG_ARTIFACT_TERMINAL: {
 		struct artifact_live *live;
-		struct artifact_record record;
+		int preserve, wake;
 
 		if (imsgev_get_payload(imsg, &payload) == -1 ||
 		    payload.data == NULL ||
@@ -1916,34 +2438,64 @@ artifact_event(struct coord *c, struct imsg *imsg)
 		    live->terminal.status != ARTIFACT_STORE_ERROR))
 			fatalx("invalid Artifact terminal state");
 		live->terminal_done = 1;
-		if (live->terminal.status == ARTIFACT_STORE_ERROR) {
-			if (live->background || live->adopted) {
-				memset(&record, 0, sizeof(record));
-				strlcpy(record.id, live->stream.id,
-				    sizeof(record.id));
-				imsgev_send(&c->iev[ROLE_TOOL],
-				    FUGU_IMSG_TOOL_ARTIFACT_CANCEL, 0,
-				    &record, sizeof(record));
-			} else if (live == c->artifact_call && !c->tool_done)
-				(void)kill(c->kids[ROLE_TOOL].pid, SIGTERM);
+		wake = c->wait == WAIT_ARTIFACT_TERMINAL &&
+		    c->artifact_wait == live;
+		if (live->drop_pending) {
+			if (live->terminal.status == ARTIFACT_STORE_ERROR) {
+				artifact_background_failure(c, live);
+			} else if (live->job_failed) {
+				if (!live->failed_discarded) {
+					artifact_discard(c, live);
+					live->failed_discarded = 1;
+				}
+				if (!live->removed)
+					artifact_live_remove(c, live);
+			} else {
+				artifact_discard(c, live);
+				artifact_drop_ack(c, live);
+				live->drop_pending = 0;
+				artifact_live_remove(c, live);
+			}
+			if (wake)
+				event_loopexit(NULL);
+			return;
 		}
+		if (live->terminal.status == ARTIFACT_STORE_ERROR &&
+		    live->background && live->adopted && live->job_id > 0) {
+			artifact_background_failure(c, live);
+			if (wake)
+				event_loopexit(NULL);
+			return;
+		}
+		if (live->terminal.status == ARTIFACT_STORE_ERROR &&
+		    !live->background && live == c->artifact_call &&
+		    !c->tool_done)
+			(void)kill(c->kids[ROLE_TOOL].pid, SIGUSR1);
 		if (live->discard_when_terminal) {
-			memset(&record, 0, sizeof(record));
-			record.retained_bytes = live->terminal.retained_bytes;
-			strlcpy(record.id, live->terminal.id, sizeof(record.id));
-			imsgev_send(&c->iev[ROLE_ARTIFACT],
-			    FUGU_IMSG_ARTIFACT_DISCARD, live->correlation,
-			    &record, sizeof(record));
-			artifact_live_remove(c, live);
+			if (!live->failed_discarded) {
+				artifact_discard(c, live);
+				if (live->job_failed)
+					live->failed_discarded = 1;
+			}
+			if (live->release_when_terminal && !live->job_failed)
+				artifact_job_control(c,
+				    FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live);
+			live->discard_when_terminal = 0;
+			live->release_when_terminal = 0;
+			preserve = live == c->artifact_call ||
+			    live == c->artifact_wait;
+			if (!preserve)
+				artifact_live_remove(c, live);
+			if (wake)
+				event_loopexit(NULL);
 			return;
 		}
-		if (c->wait == WAIT_ARTIFACT_TERMINAL &&
-		    c->artifact_wait == live)
+		if (wake)
 			event_loopexit(NULL);
 		return;
 	}
 	case FUGU_IMSG_ARTIFACT_DATA:
-		if (c->wait != WAIT_ARTIFACT_READ || id == 0 ||
+		if (id == 0 || c->artifact_read_id == 0 ||
 		    id != c->artifact_read_id || c->artifact_read_done ||
 		    imsgev_get_payload(imsg, &payload) == -1 ||
 		    payload.data == NULL || payload.len == 0 ||
@@ -1953,7 +2505,7 @@ artifact_event(struct coord *c, struct imsg *imsg)
 		buf_add(&c->artifact_read_data, payload.data, payload.len);
 		return;
 	case FUGU_IMSG_ARTIFACT_READ_RESULT:
-		if (c->wait != WAIT_ARTIFACT_READ || id == 0 ||
+		if (id == 0 || c->artifact_read_id == 0 ||
 		    id != c->artifact_read_id || c->artifact_read_done ||
 		    imsgev_get_payload(imsg, &payload) == -1 ||
 		    payload.data == NULL ||
@@ -2427,6 +2979,9 @@ allowed_from(enum fugu_role role, uint32_t type)
 	case FUGU_IMSG_TOOL_OUT:
 	case FUGU_IMSG_TOOL_DIFF:
 	case FUGU_IMSG_TOOL_RESULT:
+	case FUGU_IMSG_TOOL_ARTIFACT_ROTATE:
+	case FUGU_IMSG_TOOL_ARTIFACT_DROP:
+	case FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK:
 	case FUGU_IMSG_TOOL_TURN_ACK:
 	case FUGU_IMSG_CTX_DATA:
 	case FUGU_IMSG_CTX_END:
@@ -2726,8 +3281,27 @@ coord_dispatch(int fd, short event, void *arg)
 		case FUGU_IMSG_TOOL_OUT:
 		case FUGU_IMSG_TOOL_DIFF:
 		case FUGU_IMSG_TOOL_RESULT:
+		case FUGU_IMSG_TOOL_ARTIFACT_ROTATE:
 			tool_event(coord, &imsg);
 			break;
+		case FUGU_IMSG_TOOL_ARTIFACT_DROP: {
+			struct imsgev_payload payload;
+
+			if (imsgev_get_payload(&imsg, &payload) == -1)
+				fatalx("bad Artifact spool retirement payload");
+			artifact_drop_event(coord, imsg_get_id(&imsg),
+			    payload.data, payload.len);
+			break;
+		}
+		case FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK: {
+			struct imsgev_payload payload;
+
+			if (imsgev_get_payload(&imsg, &payload) == -1)
+				fatalx("bad Artifact cancellation ACK payload");
+			artifact_cancel_ack_event(coord, imsg_get_id(&imsg),
+			    payload.data, payload.len);
+			break;
+		}
 		case FUGU_IMSG_TOOL_TURN_ACK:
 			tool_ctl_event(coord, &imsg);
 			break;
@@ -2814,12 +3388,22 @@ send_request(struct coord *c, struct buf *body, uint32
 }
 
 static int
-artifact_tool_name(const char *name)
+shell_output_job_id(const struct tool_call *tc)
 {
-	return (strcmp(name, "shell") == 0 || strcmp(name, "grep") == 0 ||
-	    strcmp(name, "find") == 0 || strcmp(name, "ls") == 0 ||
-	    strcmp(name, "shell_bg") == 0 ||
-	    strcmp(name, "shell_output") == 0);
+	struct json j;
+	int root, tok, job_id = 0;
+	int64_t number;
+
+	if (json_parse(&j, tc->input.data, tc->input.len, 0) != 0)
+		return (0);
+	root = json_root(&j);
+	if (json_is_object(&j, root) &&
+	    (tok = json_obj_get(&j, root, "id")) != -1 &&
+	    json_get_num(&j, tok, &number) == 0 && number > 0 &&
+	    number <= INT_MAX)
+		job_id = (int)number;
+	json_done(&j);
+	return (job_id);
 }
 
 static void artifact_storage_failure(struct coord *);
@@ -2837,6 +3421,124 @@ artifact_discard(struct coord *c, const struct artifac
 }
 
 static void
+artifact_job_control(struct coord *c, uint32_t type,
+    const struct artifact_live *live)
+{
+	struct artifact_record record;
+
+	if (live->job_id <= 0)
+		return;
+	memset(&record, 0, sizeof(record));
+	strlcpy(record.id, live->stream.id, sizeof(record.id));
+	imsgev_send(&c->iev[ROLE_TOOL], type, (uint32_t)live->job_id,
+	    &record, sizeof(record));
+}
+
+static int
+artifact_job_has_committed_spool(struct coord *c, int job_id)
+{
+	struct artifact_live *live;
+
+	LIST_FOREACH(live, &c->artifacts, entry)
+		if (live->job_id == job_id && live->background && live->adopted &&
+		    !live->turn_owned)
+			return (1);
+	return (0);
+}
+
+static void
+artifact_background_failure(struct coord *c, struct artifact_live *failed)
+{
+	struct artifact_live *live, *next;
+	int job_id = failed->job_id;
+	int notify = 1, pending_abort;
+
+	if (job_id <= 0)
+		return;
+	LIST_FOREACH(live, &c->artifacts, entry)
+		if (live->job_id == job_id && live->job_failed)
+			notify = 0;
+	pending_abort = c->tool_ctl_expected == FUGU_IMSG_TOOL_TURN_ABORT &&
+	    !artifact_job_has_committed_spool(c, job_id);
+	if (notify && !pending_abort) {
+		LIST_FOREACH(live, &c->artifacts, entry)
+			if (live->job_id == job_id)
+				live->cancel_hold = 1;
+		failed->cancel_pending = 1;
+		artifact_job_control(c, FUGU_IMSG_TOOL_ARTIFACT_CANCEL, failed);
+	}
+	LIST_FOREACH_SAFE(live, &c->artifacts, entry, next) {
+		int preserve, wake;
+
+		if (live->job_id != job_id)
+			continue;
+		live->job_failed = 1;
+		live->release_when_terminal = 0;
+		preserve = live == c->artifact_call ||
+		    live == c->artifact_wait ||
+		    (live == failed && c->wait == WAIT_TOOL &&
+		    c->artifact_call != NULL &&
+		    c->artifact_call_job_id == job_id);
+		if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) {
+			if (!preserve)
+				artifact_live_remove(c, live);
+			continue;
+		}
+		if (!live->terminal_done) {
+			live->discard_when_terminal = 1;
+			continue;
+		}
+		wake = c->wait == WAIT_ARTIFACT_TERMINAL &&
+		    c->artifact_wait == live;
+		if (!live->failed_discarded) {
+			artifact_discard(c, live);
+			live->failed_discarded = 1;
+		}
+		live->discard_when_terminal = 0;
+		if (!preserve)
+			artifact_live_remove(c, live);
+		if (wake)
+			event_loopexit(NULL);
+	}
+}
+
+/* Settle the frozen side of a published rotation when its ordinary result was
+ * abandoned.  The replacement remains live for a detached committed job; a
+ * Turn-owned replacement is removed by the normal Artifact Turn abort. */
+static void
+artifact_cancelled_rotation(struct coord *c, struct artifact_live *live)
+{
+	struct artifact_live *frozen;
+
+	if (live == NULL || !live->rotation_done)
+		return;
+	frozen = artifact_rotation_frozen(c, live);
+	if (frozen == NULL) {
+		if (!live->job_failed)
+			fatalx("missing cancelled Artifact spool rotation");
+		return;
+	}
+	if (frozen->job_failed)
+		return;
+	if (frozen->stream.status == ARTIFACT_STREAM_UNAVAILABLE) {
+		artifact_live_remove(c, frozen);
+		return;
+	}
+	if (frozen->terminal_done) {
+		if (!frozen->failed_discarded)
+			artifact_discard(c, frozen);
+		if (!frozen->turn_owned && !frozen->drop_pending)
+			artifact_job_control(c,
+			    FUGU_IMSG_TOOL_ARTIFACT_RELEASE, frozen);
+		artifact_live_remove(c, frozen);
+	} else {
+		frozen->discard_when_terminal = 1;
+		frozen->release_when_terminal = !frozen->turn_owned &&
+		    !frozen->drop_pending;
+	}
+}
+
+static void
 artifact_keep(struct coord *c, const struct artifact_live *live)
 {
 	struct artifact_record *record;
@@ -2876,11 +3578,58 @@ artifact_tool_result_valid(const struct tool_result *r
 	if (live->stream.status == ARTIFACT_STREAM_AVAILABLE)
 		return (artifact_id_wire_valid(r->artifact_id) &&
 		    strcmp(r->artifact_id, live->stream.id) == 0);
-	return (!r->keep_artifact && artifact_id_zero(r->artifact_id) &&
-	    r->termination != ARTIFACT_TERM_STORAGE_ERROR);
+	return (!r->keep_artifact && artifact_id_zero(r->artifact_id));
 }
 
 static int
+artifact_cancelled_tool_result_valid(struct coord *c,
+    const struct artifact_live *live, const struct tool_result *result)
+{
+	struct artifact_live frozen;
+
+	if (live == NULL)
+		return (c->artifact_call_id == 0 &&
+		    artifact_result_fields_zero(result));
+	if (c->artifact_call_id == 0 ||
+	    live->correlation != c->artifact_call_id)
+		return (0);
+	if (c->artifact_call_shell_output) {
+		if (!result->artifact ||
+		    result->spool_frozen != live->rotation_done)
+			return (0);
+		if (!result->spool_frozen)
+			return (!result->spool_adopted && !result->spool_detach &&
+			    (result->background_job_id == 0 ||
+			    result->background_job_id == live->job_id) &&
+			    artifact_tool_result_valid(result, live));
+		if (!artifact_result_matches_rotation(result, live))
+			return (0);
+		memset(&frozen, 0, sizeof(frozen));
+		frozen.stream_done = 1;
+		if (artifact_id_zero(live->frozen_id))
+			frozen.stream.status = ARTIFACT_STREAM_UNAVAILABLE;
+		else {
+			frozen.stream.status = ARTIFACT_STREAM_AVAILABLE;
+			memcpy(frozen.stream.id, live->frozen_id,
+			    sizeof(frozen.stream.id));
+		}
+		return (artifact_tool_result_valid(result, &frozen));
+	}
+	if (result->spool_frozen || result->spool_detach)
+		return (0);
+	if (c->artifact_call_shell_bg && result->spool_adopted) {
+		return (!result->is_error && !result->artifact &&
+		    !result->keep_artifact && !result->artifact_cap_reached &&
+		    result->background_job_id > 0 &&
+		    result->termination == ARTIFACT_TERM_EXIT &&
+		    result->observed_bytes == 0 && result->retained_bytes == 0 &&
+		    artifact_result_names_call_stream(result, live));
+	}
+	return (!result->spool_adopted && result->background_job_id == 0 &&
+	    artifact_tool_result_valid(result, live));
+}
+
+static int
 artifact_inline_result_valid(const struct tool_result *r)
 {
 	struct artifact_live unavailable;
@@ -2894,7 +3643,7 @@ artifact_inline_result_valid(const struct tool_result 
 static int
 artifact_wait_terminal(struct coord *c, struct artifact_live *live)
 {
-	int rc;
+	int rc, result = 0;
 
 	while (!live->terminal_done) {
 		c->artifact_wait = live;
@@ -2902,37 +3651,87 @@ artifact_wait_terminal(struct coord *c, struct artifac
 		rc = event_dispatch();
 		if (rc == -1)
 			fatal("event_dispatch Artifact terminal");
-		if (rc == 1 && !live->terminal_done)
-			return (-1);
+		if (rc == 1 && !live->terminal_done) {
+			result = -1;
+			break;
+		}
 	}
 	c->wait = WAIT_NONE;
-	c->artifact_wait = NULL;
-	return (0);
+	if (c->artifact_wait == live)
+		c->artifact_wait = NULL;
+	return (result);
 }
 
 static int
-artifact_settle(struct coord *c, struct artifact_live *live,
+artifact_settle_impl(struct coord *c, struct artifact_live *live,
     const struct tool_result *result)
 {
 	struct artifact_record record;
 
-	if (!live->stream_done ||
-	    !artifact_tool_result_valid(result, live))
+	if (!live->stream_done)
 		return (-1);
+	if (live->failed_discarded || live->job_failed) {
+		if (live->stream.status == ARTIFACT_STREAM_AVAILABLE &&
+		    !live->terminal_done) {
+			live->discard_when_terminal = 1;
+			if (artifact_wait_terminal(c, live) == -1)
+				return (-1);
+		}
+		artifact_storage_failure(c);
+		if (!live->removed)
+			artifact_live_remove(c, live);
+		return (0);
+	}
+	if (!artifact_tool_result_valid(result, live)) {
+		if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) {
+			if (artifact_wait_terminal(c, live) == -1) {
+				live->discard_when_terminal = 1;
+				return (-1);
+			}
+			if (live->failed_discarded || live->job_failed) {
+				artifact_storage_failure(c);
+				if (!live->removed)
+					artifact_live_remove(c, live);
+				return (0);
+			}
+			artifact_discard(c, live);
+			if (live->background && live->adopted && live->job_id > 0 &&
+			    !live->drop_pending &&
+			    live->terminal.status != ARTIFACT_STORE_ERROR)
+				artifact_job_control(c,
+				    FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live);
+		}
+		artifact_live_remove(c, live);
+		return (-1);
+	}
 	if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE) {
 		artifact_live_remove(c, live);
 		return (0);
 	}
 	if (artifact_wait_terminal(c, live) == -1)
 		return (-1);
-	if (live->terminal.status == ARTIFACT_STORE_ERROR) {
+	if (live->failed_discarded || live->job_failed) {
 		artifact_storage_failure(c);
+		if (!live->removed)
+			artifact_live_remove(c, live);
+		return (0);
+	}
+	if (live->terminal.status == ARTIFACT_STORE_ERROR ||
+	    result->termination == ARTIFACT_TERM_STORAGE_ERROR) {
+		artifact_storage_failure(c);
 		artifact_discard(c, live);
 	} else {
 		if (live->terminal.retained_bytes != result->retained_bytes ||
 		    (live->terminal.status == ARTIFACT_STORE_CAP &&
-		    !result->artifact_cap_reached))
+		    !result->artifact_cap_reached)) {
+			artifact_discard(c, live);
+			if (live->background && live->adopted && live->job_id > 0 &&
+			    !live->drop_pending)
+				artifact_job_control(c,
+				    FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live);
+			artifact_live_remove(c, live);
 			return (-1);
+		}
 		if (result->keep_artifact) {
 			if (live->background) {
 				memset(&record, 0, sizeof(record));
@@ -2947,10 +3746,26 @@ artifact_settle(struct coord *c, struct artifact_live 
 		} else
 			artifact_discard(c, live);
 	}
+	if (live->background && live->adopted && live->job_id > 0 &&
+	    !live->drop_pending &&
+	    live->terminal.status != ARTIFACT_STORE_ERROR)
+		artifact_job_control(c, FUGU_IMSG_TOOL_ARTIFACT_RELEASE, live);
 	artifact_live_remove(c, live);
 	return (0);
 }
 
+static int
+artifact_settle(struct coord *c, struct artifact_live *live,
+    const struct tool_result *result)
+{
+	int rc;
+
+	artifact_live_pin(live);
+	rc = artifact_settle_impl(c, live, result);
+	artifact_live_unpin(live);
+	return (rc);
+}
+
 static void
 artifact_storage_failure(struct coord *c)
 {
@@ -3048,7 +3863,7 @@ run_output_read(struct coord *c, struct tool_call *tc)
 	struct artifact_read_req req;
 	enum artifact_page_encoding encoding;
 	const char *status, *message;
-	int retry, rc;
+	int interrupted, retry, rc;
 
 	buf_reset(&c->tool_out);
 	buf_reset(&c->diff);
@@ -3069,11 +3884,25 @@ run_output_read(struct coord *c, struct tool_call *tc)
 	c->wait = WAIT_ARTIFACT_READ;
 	rc = event_dispatch();
 	c->wait = WAIT_NONE;
+	if (rc == -1)
+		fatal("event_dispatch output_read");
+	interrupted = c->cancelled || c->want_switch || c->want_quit ||
+	    !c->artifact_read_done;
+	/* Cancellation can become active in the same libevent pass as exact
+	 * DATA/READ_RESULT frames.  Keep their correlation live and synchronously
+	 * consume the bounded response before Turn rollback pumps this channel. */
+	while (!c->artifact_read_done) {
+		c->wait = WAIT_ARTIFACT_READ;
+		rc = event_dispatch();
+		c->wait = WAIT_NONE;
+		if (rc == -1)
+			fatal("event_dispatch cancelled output_read");
+		if (rc == 1 && !c->artifact_read_done)
+			fatalx("output_read lost its event source while draining");
+	}
 	c->artifact_read_id = 0;
 	memset(&c->artifact_read_req, 0, sizeof(c->artifact_read_req));
-	if (rc == -1)
-		fatal("event_dispatch output_read");
-	if (!c->artifact_read_done) {
+	if (interrupted) {
 		output_read_error(c, "cancelled", "output_read was cancelled");
 		return;
 	}
@@ -3120,23 +3949,30 @@ run_tool(struct coord *c, struct tool_call *tc)
 {
 	struct imsgev	*iev = &c->iev[ROLE_TOOL];
 	struct artifact_begin begin;
-	struct artifact_live *live = NULL, *result_live = NULL;
-	struct artifact_record record;
+	struct artifact_live *live = NULL, *call_live = NULL,
+	    *result_live = NULL;
 	struct tool_req	 req;
 	uint32_t	 id = 0;
-	int artifact, shell_bg, shell_output, rc;
+	int artifact, rotation_validated = 0, shell_bg, shell_output, rc;
 
-	artifact = artifact_tool_name(tc->name);
+	artifact = artifact_capture_tool_name(tc->name);
 	shell_bg = strcmp(tc->name, "shell_bg") == 0;
 	shell_output = strcmp(tc->name, "shell_output") == 0;
 	c->artifact_call_id = 0;
+	c->artifact_call_job_id = shell_output ? shell_output_job_id(tc) : 0;
+	c->artifact_call_shell_bg = shell_bg;
+	c->artifact_call_shell_output = shell_output;
 	c->artifact_call = NULL;
 	memset(&c->tool_result, 0, sizeof(c->tool_result));
 	if (artifact) {
 		id = agent_unique_id(c);
 		c->artifact_call_id = id;
 		live = artifact_live_new(c, id, shell_bg || shell_output);
+		artifact_live_pin(live);
+		call_live = live;
 		c->artifact_call = live;
+		if (shell_output && c->artifact_call_job_id > 0)
+			live->job_id = c->artifact_call_job_id;
 		memset(&begin, 0, sizeof(begin));
 		begin.kind = shell_bg || shell_output ? ARTIFACT_BEGIN_SPOOL :
 		    ARTIFACT_BEGIN_FOREGROUND;
@@ -3174,22 +4010,98 @@ run_tool(struct coord *c, struct tool_call *tc)
 	}
 	if (rc == -1)
 		fatal("event_dispatch tool");
-	if (!c->tool_done)
+	if (!c->tool_done) {
+		if (call_live != NULL) {
+			int job_id = call_live->job_id;
+
+			artifact_live_unpin(call_live);
+			if (job_id > 0)
+				artifact_cancel_retire(c, job_id);
+		}
 		return;
+	}
 	if (!artifact)
 		return;
+	if (!shell_output && c->tool_result.spool_frozen) {
+		c->turn_data_exceeded = 1;
+		goto done;
+	}
+	if (shell_output) {
+		if (c->tool_result.spool_frozen != live->rotation_done) {
+			c->turn_data_exceeded = 1;
+			goto done;
+		}
+		if (c->tool_result.spool_frozen) {
+			if (!artifact_result_matches_rotation(&c->tool_result,
+			    live)) {
+				c->turn_data_exceeded = 1;
+				goto done;
+			}
+			rotation_validated = 1;
+		} else {
+			struct tool_result rejected;
+			const struct tool_result *settle_result = &c->tool_result;
+			int invalid;
 
+			invalid = c->tool_result.spool_adopted ||
+			    c->tool_result.spool_detach ||
+			    !c->tool_result.artifact ||
+			    (c->tool_result.background_job_id != 0 &&
+			    c->tool_result.background_job_id !=
+			    c->artifact_call_job_id) ||
+			    !artifact_result_names_call_stream(&c->tool_result,
+			    live);
+			if (invalid) {
+				rejected = c->tool_result;
+				rejected.artifact = 0;
+				settle_result = &rejected;
+			}
+			if (artifact_settle(c, live, settle_result) == -1 || invalid)
+				c->turn_data_exceeded = 1;
+			live = NULL;
+			goto done;
+		}
+	}
+	if (live->job_failed) {
+		struct artifact_live *failed, *next;
+		int job_id = live->job_id;
+
+		if (artifact_settle(c, live, &c->tool_result) == -1) {
+			c->turn_data_exceeded = 1;
+			goto done;
+		}
+		live = NULL;
+		LIST_FOREACH_SAFE(failed, &c->artifacts, entry, next) {
+			if (failed->job_id != job_id || !failed->job_failed)
+				continue;
+			if (!failed->terminal_done) {
+				failed->discard_when_terminal = 1;
+				continue;
+			}
+			if (!failed->failed_discarded)
+				artifact_discard(c, failed);
+			artifact_live_remove(c, failed);
+		}
+		goto done;
+	}
 	if (!shell_bg && !shell_output) {
-		if (c->tool_result.spool_adopted ||
+		if (c->tool_result.spool_frozen ||
+		    c->tool_result.spool_adopted ||
+		    c->tool_result.background_job_id != 0 ||
 		    artifact_settle(c, live, &c->tool_result) == -1)
 			c->turn_data_exceeded = 1;
 		goto done;
 	}
 
 	if (shell_bg) {
+		if (c->tool_result.spool_frozen) {
+			c->turn_data_exceeded = 1;
+			goto done;
+		}
 		if (c->tool_result.spool_adopted) {
 			if (c->tool_result.artifact ||
 			    c->tool_result.spool_detach || c->tool_result.is_error ||
+			    c->tool_result.background_job_id <= 0 ||
 			    (live->stream.status == ARTIFACT_STREAM_AVAILABLE &&
 			    (!artifact_id_wire_valid(c->tool_result.artifact_id) ||
 			    strcmp(c->tool_result.artifact_id,
@@ -3199,10 +4111,18 @@ run_tool(struct coord *c, struct tool_call *tc)
 				c->turn_data_exceeded = 1;
 				goto done;
 			}
+			live->job_id = c->tool_result.background_job_id;
 			live->adopted = 1;
-			if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE)
-				artifact_live_remove(c, live);
-		} else if (artifact_settle(c, live, &c->tool_result) == -1)
+			if (live->terminal_done &&
+			    live->terminal.status == ARTIFACT_STORE_ERROR) {
+				artifact_background_failure(c, live);
+				artifact_storage_failure(c);
+				if (!live->removed)
+					artifact_live_remove(c, live);
+				live = NULL;
+			}
+		} else if (c->tool_result.background_job_id != 0 ||
+		    artifact_settle(c, live, &c->tool_result) == -1)
 			c->turn_data_exceeded = 1;
 		goto done;
 	}
@@ -3213,6 +4133,8 @@ run_tool(struct coord *c, struct tool_call *tc)
 		c->turn_data_exceeded = 1;
 		goto done;
 	}
+	if (c->tool_result.background_job_id > 0)
+		live->job_id = c->tool_result.background_job_id;
 	if (artifact_id_wire_valid(c->tool_result.artifact_id))
 		result_live = artifact_live_by_id(c,
 		    c->tool_result.artifact_id);
@@ -3220,35 +4142,72 @@ run_tool(struct coord *c, struct tool_call *tc)
 		c->turn_data_exceeded = 1;
 		goto done;
 	}
-	if (c->tool_result.spool_adopted) {
-		live->adopted = 1;
-		if (c->tool_result.spool_detach) {
-			live->turn_owned = 0;
-			if (live->stream.status == ARTIFACT_STREAM_AVAILABLE) {
-				memset(&record, 0, sizeof(record));
-				strlcpy(record.id, live->stream.id,
-				    sizeof(record.id));
-				imsgev_send(&c->iev[ROLE_ARTIFACT],
-				    FUGU_IMSG_ARTIFACT_DETACH, live->correlation,
-				    &record, sizeof(record));
-			}
-		}
-	} else if (c->tool_result.spool_detach) {
-		c->turn_data_exceeded = 1;
-		goto done;
-	}
+	if ((c->tool_result.spool_adopted &&
+	    c->tool_result.background_job_id <= 0) ||
+	    (result_live != NULL && result_live != live &&
+	    (c->tool_result.background_job_id <= 0 ||
+	    result_live->job_id != c->tool_result.background_job_id)) ||
+	    (c->tool_result.background_job_id == 0 && result_live != live)) {
+		int result_is_live = result_live == live;
 
+		if (result_live != NULL)
+			(void)artifact_settle(c, result_live, &c->tool_result);
+		if (result_is_live)
+			live = NULL;
+		c->turn_data_exceeded = 1;
+		goto done;
+	}
 	if (result_live != NULL) {
+		int result_is_live = result_live == live;
+		int result_failed = result_live->failed_discarded ||
+		    result_live->job_failed;
+
 		if (artifact_settle(c, result_live, &c->tool_result) == -1)
 			c->turn_data_exceeded = 1;
-		if (result_live == live)
+		if (result_is_live)
 			live = NULL;
-	} else if (!artifact_inline_result_valid(&c->tool_result))
-		c->turn_data_exceeded = 1;
+		else if (result_failed && live != NULL) {
+			if (live->terminal_done) {
+				if (!live->failed_discarded)
+					artifact_discard(c, live);
+				if (!live->removed)
+					artifact_live_remove(c, live);
+				live = NULL;
+			} else
+				live->discard_when_terminal = 1;
+		}
+	} else {
+		struct artifact_live *unavailable;
 
+		if (rotation_validated)
+			unavailable = artifact_rotation_frozen(c, live);
+		else if (live != NULL && live->stream_done &&
+		    live->stream.status == ARTIFACT_STREAM_UNAVAILABLE)
+			unavailable = live;
+		else
+			unavailable = NULL;
+		if (!artifact_inline_result_valid(&c->tool_result) ||
+		    unavailable == NULL || unavailable->stream.status !=
+		    ARTIFACT_STREAM_UNAVAILABLE)
+			c->turn_data_exceeded = 1;
+		if (unavailable != NULL) {
+			int result_is_live = unavailable == live;
+
+			artifact_live_remove(c, unavailable);
+			if (result_is_live)
+				live = NULL;
+		}
+	}
+
 	if (live != NULL && c->tool_result.spool_adopted) {
-		if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE)
-			artifact_live_remove(c, live);
+		if (live->terminal_done &&
+		    live->terminal.status == ARTIFACT_STORE_ERROR) {
+			artifact_background_failure(c, live);
+			artifact_storage_failure(c);
+			if (!live->removed)
+				artifact_live_remove(c, live);
+			live = NULL;
+		}
 	} else if (live != NULL) {
 		if (live->stream.status == ARTIFACT_STREAM_UNAVAILABLE)
 			artifact_live_remove(c, live);
@@ -3260,8 +4219,25 @@ run_tool(struct coord *c, struct tool_call *tc)
 		}
 	}
 done:
+	if (call_live != NULL && call_live->rotation_done) {
+		struct artifact_live *frozen;
+
+		frozen = artifact_rotation_frozen(c, call_live);
+		if (frozen != NULL && !frozen->cancel_retire)
+			artifact_cancelled_rotation(c, call_live);
+	}
 	c->artifact_call = NULL;
 	c->artifact_call_id = 0;
+	c->artifact_call_job_id = 0;
+	c->artifact_call_shell_bg = 0;
+	c->artifact_call_shell_output = 0;
+	if (call_live != NULL) {
+		int job_id = call_live->job_id;
+
+		artifact_live_unpin(call_live);
+		if (job_id > 0)
+			artifact_cancel_retire(c, job_id);
+	}
 }
 
 /* Send one brokered call to fugu-web and pump events until its result. */
@@ -5933,6 +6909,7 @@ coord_run(struct fugu_conf *cf, const char *prompt, si
 	int			 role, rc;
 
 	memset(&c, 0, sizeof(c));
+	LIST_INIT(&c.artifacts);
 	c.conf = cf;
 	c.debug = debug;
 	c.verbose = verbose;
@@ -5981,6 +6958,7 @@ coord_run(struct fugu_conf *cf, const char *prompt, si
 	event_init();
 	/* The Artifact Role owns the lifetime Session lock.  Resolve the exact
 	 * Session id and managed root before any replay/open can mutate a Journal. */
+	artifact_root_prepare(&c);
 	if (journal_dir(sessdir, sizeof(sessdir)) == -1)
 		fatalx("cannot use ~/.fugu/sessions");
 	if (resume_id != NULL) {
@@ -5993,14 +6971,6 @@ coord_run(struct fugu_conf *cf, const char *prompt, si
 		strlcpy(c.artifact_session, id, sizeof(c.artifact_session));
 		c.artifact_fresh = 1;
 	}
-	{
-		const char *home = getenv("HOME");
-
-		if (home == NULL || home[0] != '/' ||
-		    (size_t)snprintf(c.artifact_root, sizeof(c.artifact_root),
-		    "%s/.fugu/artifacts", home) >= sizeof(c.artifact_root))
-			fatalx("cannot use ~/.fugu/artifacts");
-	}
 	protect_snapshot_build(&c);
 	if ((c.devnull_fd = priv_open_devnull()) == -1)
 		fatal("open /dev/null");
blob - /dev/null
blob + 6f94c12c31eada82a6c98a61924ee5123dc934e7 (mode 644)
--- /dev/null
+++ src/fugu/artifact_scope.c
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ */
+
+#include <string.h>
+
+#include "artifact_scope.h"
+
+int
+artifact_capture_tool_name(const char *name)
+{
+	return (strcmp(name, "shell") == 0 || strcmp(name, "grep") == 0 ||
+	    strcmp(name, "find") == 0 || strcmp(name, "ls") == 0 ||
+	    strcmp(name, "shell_bg") == 0 ||
+	    strcmp(name, "shell_output") == 0);
+}
blob - /dev/null
blob + f0c42eb818f25d44cc67f935b5e181aa3e802ef5 (mode 644)
--- /dev/null
+++ src/fugu/artifact_scope.h
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ */
+
+#ifndef ARTIFACT_SCOPE_H
+#define ARTIFACT_SCOPE_H
+
+/* Lead-local producers whose raw output enters managed Artifact storage. */
+int artifact_capture_tool_name(const char *);
+
+#endif /* ARTIFACT_SCOPE_H */
blob - af93fcde5229fecbd3eed0c8df674054cfb875b1
blob + bfc4f08bda7c3ccfcec4f400e53c9daa68b7f6e7
--- src/fugu/fugu.1
+++ src/fugu/fugu.1
@@ -691,6 +691,26 @@ A failed, cancelled, or abandoned turn contributes no 
 state; a turn stopped by the 50-iteration cap is complete and remains
 resumable.
 Queued but undelivered messages are never journaled.
+For Lead-local shell and search tools, raw output that does not fit the bounded
+Tool result may be retained under the Owner-only managed root
+.Pa ~/.fugu/artifacts/ .
+The model receives only an opaque Session-local identifier; no storage path is
+written to the Journal or exposed through a Tool result.
+Committed retained output survives resume,
+.Cm /clear ,
+and Compaction until quota cleanup evicts it.
+Retained output can contain credentials or other secrets printed by a command;
+Owners should treat the entire managed root as sensitive data.
+At startup,
+.Nm
+may create or validate the owner-only
+.Pa ~/.fugu
+directory before resolving this root.
+If its physical path overlaps the project tree or another persistent Lead Tool
+filesystem authority,
+.Nm
+refuses the invocation before spawning workers, opening or changing a Journal,
+or contacting a Provider.
 .
 .Sh ENVIRONMENT
 .Bl -tag -width FUGU_CONF
@@ -724,6 +744,9 @@ Commented configuration installed by the package.
 Personal project-independent context.
 .It Pa ~/.fugu/sessions/
 Per-session journals.
+.It Pa ~/.fugu/artifacts/
+Owner-only managed storage for retained Tool output; contents may include
+secrets printed by commands.
 .It Pa ~/.fugu/skills/name/SKILL.md
 User-installed skill.
 .It Pa FUGU.md
blob - 175e290d0c0abc4258fa50bbff49452e7a488f0f
blob + f95164804fde4d69524c372dd3204fca381c4637
--- src/fugu/proto.h
+++ src/fugu/proto.h
@@ -200,10 +200,18 @@ enum fugu_imsg {
 	FUGU_IMSG_TOOL_OUT,	/* one result chunk (zero or more) */
 	FUGU_IMSG_TOOL_DIFF,	/* presentation-only diff chunk (zero or more) */
 	FUGU_IMSG_TOOL_RESULT,	/* struct tool_result: is_error; terminal */
-	/* A typed pipe write end accompanies TOOL_ARTIFACT_STREAM.  It is
-	 * accepted only by the Lead executor, never by fugu-tool -r. */
+	/* A successful shell_output rotation is durable control state, not part of
+	 * the discardable result stream.  It precedes TOOL_OUT/TOOL_RESULT. */
+	FUGU_IMSG_TOOL_ARTIFACT_ROTATE, /* struct artifact_spool_rotate */
+	FUGU_IMSG_TOOL_ARTIFACT_DROP, /* struct artifact_spool_drop */
+	FUGU_IMSG_TOOL_ARTIFACT_DROP_ACK, /* struct artifact_spool_drop */
+	/* A typed producer endpoint accompanies TOOL_ARTIFACT_STREAM.  OpenBSD
+	 * reports both pipe endpoints as O_RDWR; protocol role, not access flags,
+	 * makes this the write side.  Only the Lead executor accepts it. */
 	FUGU_IMSG_TOOL_ARTIFACT_STREAM, /* struct artifact_stream + write fd */
-	FUGU_IMSG_TOOL_ARTIFACT_CANCEL, /* exact current spool id failed */
+	FUGU_IMSG_TOOL_ARTIFACT_CANCEL, /* exact job + current/historical id */
+	FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK, /* struct artifact_job_cancel_ack */
+	FUGU_IMSG_TOOL_ARTIFACT_RELEASE, /* old spool reached settlement */
 
 	/* Private Artifact storage.  Control is Coordinator-brokered; only the
 	 * raw-byte pipe created by BEGIN forms the narrow direct data plane. */
@@ -490,14 +498,46 @@ struct tool_result {
 	int		 artifact;
 	int		 keep_artifact;
 	int		 artifact_cap_reached;
+	int		 spool_frozen;
 	int		 spool_adopted;
 	int		 spool_detach;
+	int		 background_job_id;
 	int		 termination;	/* enum artifact_termination */
 	uint64_t	 observed_bytes;
 	uint64_t	 retained_bytes;
 	char		 artifact_id[FUGU_ARTIFACT_ID_BUFSZ];
 };
 
+/* Tool -> Coordinator publication of an immutable background spool freeze.
+ * replacement_adopted says whether it was also a rotation.  The imsg header
+ * id is the replacement stream's Tool-call correlation; even when no
+ * replacement was adopted it identifies the unused Turn-owned stream.  A
+ * zero id array denotes the corresponding unavailable reservation. */
+struct artifact_spool_rotate {
+	uint32_t job_id;
+	uint32_t replacement_adopted;
+	uint32_t detach;
+	char	 frozen_id[FUGU_ARTIFACT_ID_BUFSZ];
+	char	 replacement_id[FUGU_ARTIFACT_ID_BUFSZ];
+};
+
+/* Tool -> Coordinator retirement of one completed, unpolled background
+ * spool, and the Coordinator's exact-key acknowledgement.  The imsg header
+ * id is the Tool-call correlation that originally supplied this spool.  An
+ * exactly all-zero artifact_id denotes an unavailable reservation. */
+struct artifact_spool_drop {
+	uint32_t job_id;
+	char	 artifact_id[FUGU_ARTIFACT_ID_BUFSZ];
+};
+
+/* Tool -> Coordinator completion of an exact job-wide cancellation.  The
+ * imsg header id also equals job_id.  A preceding DROP on this ordered socket
+ * was already published; an unqueued DROP for this job was removed instead. */
+struct artifact_job_cancel_ack {
+	uint32_t job_id;
+	char	 artifact_id[FUGU_ARTIFACT_ID_BUFSZ];
+};
+
 enum artifact_stream_status {
 	ARTIFACT_STREAM_AVAILABLE = 0,
 	ARTIFACT_STREAM_UNAVAILABLE
blob - d486d07c930a48197d59feb62b0d2d5c0079ef5f
blob + 99c942ff5464de8407bbe7eb2c5268172ce75424
--- src/fugu-artifact/main.c
+++ src/fugu-artifact/main.c
@@ -7,6 +7,7 @@
  */
 
 #include <sys/types.h>
+#include <sys/queue.h>
 
 #include <event.h>
 #include <imsg.h>
@@ -14,6 +15,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <syslog.h>
+#include <time.h>
 #include <unistd.h>
 
 #include "buf.h"
@@ -22,15 +24,19 @@
 #include "proto.h"
 #include "store.h"
 #include "worker.h"
+#include "xmalloc.h"
 
+struct drain_event;
+LIST_HEAD(drain_list, drain_event);
+
 struct artifact_state {
 	struct worker *worker;
 	struct artifact_store *store;
-	struct drain_event *drains;
+	struct drain_list drains;
 };
 
 struct drain_event {
-	struct drain_event *next;
+	LIST_ENTRY(drain_event) entry;
 	struct event ev;
 	struct artifact_state *state;
 	struct artifact_pending *pending;
@@ -40,15 +46,54 @@ struct drain_event {
 static struct artifact_state *cleanup_state;
 
 static void
+artifact_store_error_test(struct artifact_terminal *terminal)
+{
+	static int injected;
+	const char *value, *errstr;
+	struct timespec delay;
+	long long ms;
+
+	if (injected || issetugid() || getenv("FUGU_SELFTEST") == NULL ||
+	    (value = getenv("FUGU_ARTIFACT_STORE_ERROR_DELAY_MS")) == NULL)
+		return;
+	ms = strtonum(value, 1, 5000, &errstr);
+	if (errstr != NULL)
+		return;
+	injected = 1;
+	delay.tv_sec = (time_t)(ms / 1000);
+	delay.tv_nsec = (long)(ms % 1000) * 1000000L;
+	(void)nanosleep(&delay, NULL);
+	log_warnx("fugu-artifact: selftest STORE_ERROR injected");
+	terminal->status = ARTIFACT_STORE_ERROR;
+}
+
+static void
+artifact_read_test_delay(void)
+{
+	const char *value, *errstr;
+	struct timespec delay;
+	long long ms;
+
+	if (issetugid() ||
+	    (value = getenv("FUGU_ARTIFACT_READ_DELAY_MS")) == NULL)
+		return;
+	ms = strtonum(value, 1, 5000, &errstr);
+	if (errstr != NULL)
+		return;
+	delay.tv_sec = (time_t)(ms / 1000);
+	delay.tv_nsec = (long)(ms % 1000) * 1000000L;
+	(void)nanosleep(&delay, NULL);
+}
+
+static void
 drain_unlink(struct drain_event *de)
 {
-	struct drain_event **link;
+	struct drain_event *item;
 
-	for (link = &de->state->drains; *link != NULL;
-	    link = &(*link)->next) {
-		if (*link != de)
+	LIST_FOREACH(item, &de->state->drains, entry) {
+		if (item != de)
 			continue;
-		*link = de->next;
+		LIST_REMOVE(de, entry);
 		return;
 	}
 	fatalx("fugu-artifact: lost drain event");
@@ -57,15 +102,14 @@ drain_unlink(struct drain_event *de)
 static void
 drain_cancel_owned(struct artifact_state *state)
 {
-	struct drain_event **link, *de;
+	struct drain_event *de, *next;
 
-	for (link = &state->drains; (de = *link) != NULL;) {
-		if (!artifact_pending_turn_owned(de->pending)) {
-			link = &de->next;
+	for (de = LIST_FIRST(&state->drains); de != NULL; de = next) {
+		next = LIST_NEXT(de, entry);
+		if (!artifact_pending_turn_owned(de->pending))
 			continue;
-		}
 		(void)event_del(&de->ev);
-		*link = de->next;
+		LIST_REMOVE(de, entry);
 		free(de);
 	}
 }
@@ -89,6 +133,7 @@ drain_stream(int fd, short events, void *arg)
 	rc = artifact_pending_drain(de->pending, &terminal);
 	if (rc == 0)
 		return;
+	artifact_store_error_test(&terminal);
 	event_del(&de->ev);
 	drain_unlink(de);
 	imsgev_send(&de->state->worker->iev, FUGU_IMSG_ARTIFACT_TERMINAL,
@@ -209,14 +254,11 @@ artifact_dispatch(struct worker *w, struct imsg *imsg)
 			    &stream, sizeof(stream));
 			return (0);
 		}
-		de = calloc(1, sizeof(*de));
-		if (de == NULL)
-			fatal("calloc Artifact drain");
+		de = xcalloc(1, sizeof(*de));
 		de->state = state;
 		de->pending = pending;
 		de->correlation = id;
-		de->next = state->drains;
-		state->drains = de;
+		LIST_INSERT_HEAD(&state->drains, de, entry);
 		event_set(&de->ev, artifact_pending_fd(pending),
 		    EV_READ | EV_PERSIST, drain_stream, de);
 		if (event_add(&de->ev, NULL) == -1)
@@ -256,6 +298,7 @@ artifact_dispatch(struct worker *w, struct imsg *imsg)
 
 		if (id == 0 || p == NULL || len != sizeof(struct artifact_read_req))
 			fatalx("fugu-artifact: bad read");
+		artifact_read_test_delay();
 		buf_init(&out);
 		if (artifact_store_read(state->store,
 		    (const struct artifact_read_req *)p, &out, &result) == -1)
@@ -285,6 +328,7 @@ main(int argc, char *argv[])
 	    -1)
 		fatal("fugu-artifact: pledge startup");
 	state.worker = &worker;
+	LIST_INIT(&state.drains);
 	worker.title = "fugu-artifact";
 	worker.ctx = &state;
 	worker.lockdown = artifact_lockdown;
blob - 640de1293d9cf4220161d9bcce1632da9279784c
blob + bf6bb4d01895443dae6e7d077bd2f10781b3725c
--- src/fugu-artifact/store.c
+++ src/fugu-artifact/store.c
@@ -8,6 +8,7 @@
 
 #include <sys/types.h>
 #include <sys/file.h>
+#include <sys/queue.h>
 #include <sys/stat.h>
 
 #include <dirent.h>
@@ -27,13 +28,13 @@
 #include "xmalloc.h"
 
 struct known_artifact {
-	struct known_artifact *next;
+	LIST_ENTRY(known_artifact) entry;
 	uint64_t retained;
 	char id[FUGU_ARTIFACT_ID_BUFSZ];
 };
 
 struct artifact_pending {
-	struct artifact_pending *next;
+	LIST_ENTRY(artifact_pending) entry;
 	struct artifact_store *store;
 	uint32_t correlation;
 	int file_fd;
@@ -41,6 +42,7 @@ struct artifact_pending {
 	uint64_t retained;
 	int terminal_done;
 	int terminal_status;
+	int final_linked;
 	int prepared;
 	int background;
 	int turn_owned;
@@ -48,6 +50,14 @@ struct artifact_pending {
 	char staging[FUGU_ARTIFACT_ID_BUFSZ + 16];
 };
 
+LIST_HEAD(known_list, known_artifact);
+LIST_HEAD(pending_list, artifact_pending);
+
+enum artifact_store_phase {
+	ARTIFACT_STORE_RECONCILING = 0,
+	ARTIFACT_STORE_READY
+};
+
 struct artifact_store {
 	char root[FUGU_ARTIFACT_ROOT_MAX];
 	char session[FUGU_ARTIFACT_SESSION_MAX];
@@ -56,9 +66,11 @@ struct artifact_store {
 	int root_fd;
 	int session_lock_fd;
 	int global_lock_fd;
+	enum artifact_store_phase phase;
 	int turn_open;
-	struct known_artifact *known;
-	struct artifact_pending *pending;
+	int turn_prepared;
+	struct known_list known;
+	struct pending_list pending;
 };
 
 struct eviction {
@@ -126,23 +138,61 @@ mkdir_private(const char *path)
 	if (mkdir(path, 0700) == -1 && errno != EEXIST)
 		return (-1);
 	if (lstat(path, &st) == -1 || !S_ISDIR(st.st_mode) ||
-	    (st.st_mode & 0777) != 0700)
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0700)
 		return (-1);
 	return (0);
 }
 
 static int
+lock_fd_normalize(int fd)
+{
+	int fdflags, flags;
+
+	if ((flags = fcntl(fd, F_GETFL)) == -1 ||
+	    (flags & O_ACCMODE) != O_RDWR || (flags & O_APPEND) != 0)
+		return (-1);
+	if ((flags & O_NONBLOCK) != 0 &&
+	    fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) == -1)
+		return (-1);
+	if ((flags = fcntl(fd, F_GETFL)) == -1 ||
+	    (flags & O_ACCMODE) != O_RDWR ||
+	    (flags & (O_APPEND | O_NONBLOCK)) != 0 ||
+	    (fdflags = fcntl(fd, F_GETFD)) == -1 ||
+	    (fdflags & FD_CLOEXEC) == 0)
+		return (-1);
+	return (0);
+}
+
+static int
 open_private(const char *path, int flags)
 {
-	int fd;
+	struct stat st;
+	int created = 0, fd;
 
-	if ((fd = open(path, flags | O_CLOEXEC | O_NOFOLLOW, 0600)) == -1)
-		return (-1);
-	if (fchmod(fd, 0600) == -1) {
-		close(fd);
-		return (-1);
+	fd = open(path, (flags & ~(O_CREAT | O_EXCL)) | O_NONBLOCK |
+	    O_CLOEXEC | O_NOFOLLOW);
+	if (fd == -1 && errno == ENOENT && (flags & O_CREAT)) {
+		fd = open(path, flags | O_EXCL | O_NONBLOCK | O_CLOEXEC |
+		    O_NOFOLLOW, 0600);
+		if (fd != -1)
+			created = 1;
 	}
+	if (fd == -1)
+		return (-1);
+	if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) ||
+	    st.st_uid != geteuid() || st.st_nlink != 1 || st.st_size != 0 ||
+	    (!created && (st.st_mode & 0777) != 0600))
+		goto fail;
+	if (created && fchmod(fd, 0600) == -1)
+		goto fail;
+	if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) ||
+	    st.st_uid != geteuid() || st.st_nlink != 1 || st.st_size != 0 ||
+	    (st.st_mode & 0777) != 0600 || lock_fd_normalize(fd) == -1)
+		goto fail;
 	return (fd);
+fail:
+	close(fd);
+	return (-1);
 }
 
 static int
@@ -171,7 +221,7 @@ known_find(struct artifact_store *s, const char *id)
 {
 	struct known_artifact *k;
 
-	for (k = s->known; k != NULL; k = k->next)
+	LIST_FOREACH(k, &s->known, entry)
 		if (strcmp(k->id, id) == 0)
 			return (k);
 	return (NULL);
@@ -187,10 +237,9 @@ known_add(struct artifact_store *s, const char *id, ui
 	if ((k = known_find(s, id)) != NULL)
 		return (k->retained == retained ? 0 : -1);
 	k = xcalloc(1, sizeof(*k));
-	strlcpy(k->id, id, sizeof(k->id));
+	memcpy(k->id, id, sizeof(k->id));
 	k->retained = retained;
-	k->next = s->known;
-	s->known = k;
+	LIST_INSERT_HEAD(&s->known, k, entry);
 	return (0);
 }
 
@@ -217,8 +266,10 @@ artifact_store_open(const char *root, const char *sess
 	s->root_fd = -1;
 	s->session_lock_fd = -1;
 	s->global_lock_fd = -1;
-	strlcpy(s->root, root, sizeof(s->root));
-	strlcpy(s->session, session, sizeof(s->session));
+	LIST_INIT(&s->known);
+	LIST_INIT(&s->pending);
+	memcpy(s->root, root, n + 1);
+	memcpy(s->session, session, strlen(session) + 1);
 	/* The parent ~/.fugu exists before worker startup; create only the
 	 * dedicated managed root and this Session below it. */
 	if (strlcpy(fugu, root, sizeof(fugu)) >= sizeof(fugu) ||
@@ -240,10 +291,12 @@ artifact_store_open(const char *root, const char *sess
 		goto fail;
 	if ((s->root_fd = open(root,
 	    O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)) == -1 ||
+	    fstat(s->root_fd, &st) == -1 || !S_ISDIR(st.st_mode) ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0700 ||
 	    (s->session_fd = open(s->session_path,
 	    O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)) == -1 ||
 	    fstat(s->session_fd, &st) == -1 || !S_ISDIR(st.st_mode) ||
-	    (st.st_mode & 0777) != 0700 ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0700 ||
 	    (size_t)snprintf(lock, sizeof(lock), "%s/.quota.lock", root) >=
 	    sizeof(lock) || (s->global_lock_fd = open_private(lock,
 	    O_RDWR | O_CREAT)) == -1)
@@ -257,28 +310,37 @@ fail:
 void
 artifact_store_close(struct artifact_store *s)
 {
-	struct known_artifact *k, *kn;
-	struct artifact_pending *p, *pn;
+	struct known_artifact *k;
+	struct artifact_pending *p;
+	int dirty = 0, preserve;
 
 	if (s == NULL)
 		return;
-	for (p = s->pending; p != NULL; p = pn) {
-		pn = p->next;
+	while ((p = LIST_FIRST(&s->pending)) != NULL) {
+		LIST_REMOVE(p, entry);
 		if (p->read_fd != -1)
 			close(p->read_fd);
 		if (p->file_fd != -1)
 			close(p->file_fd);
-		/* A prepared final may already be named by a synchronized Journal
-		 * terminal.  Only replay can decide whether it is committed or an
-		 * orphan; clean shutdown removes staging/live spool files only. */
-		if (!p->prepared)
-			(void)unlinkat(s->session_fd, p->staging, 0);
-		if (!p->prepared)
+		/* Only a completely prepared Turn may already be named by a
+		 * synchronized Journal terminal.  A partial prepare can have both
+		 * names linked and is wholly rolled back on shutdown. */
+		preserve = s->turn_prepared && p->prepared;
+		if (!preserve) {
+			if (unlinkat(s->session_fd, p->staging, 0) == 0)
+				dirty = 1;
+			if (p->final_linked &&
+			    unlinkat(s->session_fd, p->id, 0) == 0)
+				dirty = 1;
 			(void)order_unlink(s, p->id);
+			dirty = 1;
+		}
 		free(p);
 	}
-	for (k = s->known; k != NULL; k = kn) {
-		kn = k->next;
+	if (dirty)
+		(void)fsync(s->session_fd);
+	while ((k = LIST_FIRST(&s->known)) != NULL) {
+		LIST_REMOVE(k, entry);
 		free(k);
 	}
 	if (s->session_fd != -1)
@@ -302,7 +364,9 @@ int
 artifact_store_reconcile_add(struct artifact_store *s,
     const struct artifact_record *record)
 {
-	if (s == NULL || record == NULL || s->turn_open)
+	if (s == NULL || record == NULL ||
+	    s->phase != ARTIFACT_STORE_RECONCILING || s->turn_open ||
+	    !LIST_EMPTY(&s->pending))
 		return (-1);
 	return (known_add(s, record->id, record->retained_bytes));
 }
@@ -313,35 +377,69 @@ final_name(const char *name)
 	return (artifact_id_valid(name));
 }
 
+static int
+pending_name(const struct dirent *de)
+{
+	return (strncmp(de->d_name, ".pending-", 9) == 0);
+}
+
+static int
+artifact_name(const struct dirent *de)
+{
+	return (final_name(de->d_name) || pending_name(de));
+}
+
+static int
+reconcile_name(const struct dirent *de)
+{
+	return (artifact_name(de) ||
+	    (strncmp(de->d_name, ORDER_PREFIX, ORDER_PREFIX_LEN) == 0 &&
+	    final_name(de->d_name + ORDER_PREFIX_LEN)));
+}
+
+static int
+session_name(const struct dirent *de)
+{
+	return (de->d_name[0] != '.');
+}
+
+static void
+free_dirents(struct dirent **names, int count)
+{
+	while (count > 0)
+		free(names[--count]);
+	free(names);
+}
+
 int
 artifact_store_reconcile_finish(struct artifact_store *s)
 {
-	struct dirent *de;
-	DIR *dir;
-	int dfd = -1;
+	struct dirent **names = NULL;
+	const char *name, *id;
+	int count, i;
 
-	if (s == NULL || (dfd = dup(s->session_fd)) == -1 ||
-	    (dir = fdopendir(dfd)) == NULL) {
-		if (dfd != -1)
-			close(dfd);
+	if (s == NULL || s->phase != ARTIFACT_STORE_RECONCILING ||
+	    s->turn_open || !LIST_EMPTY(&s->pending) ||
+	    (count = scandirat(s->session_fd, ".", &names, reconcile_name,
+	    NULL)) == -1)
 		return (-1);
-	}
-	while ((de = readdir(dir)) != NULL) {
-		if (strncmp(de->d_name, ".pending-", 9) == 0 ||
-		    (final_name(de->d_name) &&
-		    known_find(s, de->d_name) == NULL) ||
-		    (strncmp(de->d_name, ORDER_PREFIX, ORDER_PREFIX_LEN) == 0 &&
-		    final_name(de->d_name + ORDER_PREFIX_LEN) &&
-		    known_find(s, de->d_name + ORDER_PREFIX_LEN) == NULL)) {
-			if (unlinkat(s->session_fd, de->d_name, 0) == -1 &&
-			    errno != ENOENT) {
-				closedir(dir);
-				return (-1);
-			}
+	for (i = 0; i < count; i++) {
+		name = names[i]->d_name;
+		id = strncmp(name, ORDER_PREFIX, ORDER_PREFIX_LEN) == 0 ?
+		    name + ORDER_PREFIX_LEN : name;
+		if (!pending_name(names[i]) && known_find(s, id) != NULL)
+			continue;
+		if (unlinkat(s->session_fd, name, 0) == -1 && errno != ENOENT) {
+			free_dirents(names, count);
+			return (-1);
 		}
 	}
-	closedir(dir);
-	return (fsync(s->session_fd));
+	free_dirents(names, count);
+	if (fault_hit(ARTIFACT_STORE_FAULT_RECONCILE_FSYNC) ||
+	    fsync(s->session_fd) == -1)
+		return (-1);
+	s->phase = ARTIFACT_STORE_READY;
+	return (0);
 }
 
 static int
@@ -370,15 +468,20 @@ read_order(int dirfd, const char *id, uint64_t *sequen
 
 	if (order_name(id, name, sizeof(name)) == -1)
 		return (-1);
-	fd = openat(dirfd, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
+	fd = openat(dirfd, name,
+	    O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW);
 	if (fd == -1)
 		return (errno == ENOENT ? 1 : -1);
+	if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 ||
+	    st.st_nlink != 1 || st.st_size != (off_t)sizeof(*sequence)) {
+		close(fd);
+		return (-1);
+	}
 	do {
 		n = read(fd, sequence, sizeof(*sequence));
 	} while (n == -1 && errno == EINTR);
-	if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) ||
-	    st.st_size != (off_t)sizeof(*sequence) ||
-	    n != (ssize_t)sizeof(*sequence) || *sequence == 0) {
+	if (n != (ssize_t)sizeof(*sequence) || *sequence == 0) {
 		close(fd);
 		return (-1);
 	}
@@ -393,97 +496,120 @@ scan_session(struct artifact_store *s, const char *ses
 {
 	char path[PATH_MAX], full[PATH_MAX];
 	struct stat st;
-	struct dirent *de;
-	DIR *dir;
-	int dfd, dirty = 0;
+	struct dirent **names = NULL;
+	const char *name;
+	int count, dfd, i, rc = -1;
 
 	if ((size_t)snprintf(path, sizeof(path), "%s/%s", s->root,
 	    session) >= sizeof(path))
 		return (-1);
-	if ((dfd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC |
-	    O_NOFOLLOW)) == -1)
+	if (current)
+		dfd = dup(s->session_fd);
+	else
+		dfd = openat(s->root_fd, session,
+		    O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
+	if (dfd == -1)
 		return (errno == ENOENT ? 0 : -1);
-	if ((dir = fdopendir(dfd)) == NULL) {
+	if (fstat(dfd, &st) == -1 || !S_ISDIR(st.st_mode) ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0700) {
 		close(dfd);
 		return (-1);
 	}
-	while ((de = readdir(dir)) != NULL) {
-		if (!final_name(de->d_name) &&
-		    strncmp(de->d_name, ".pending-", 9) != 0)
-			continue;
-		/* Once its lifetime lock is held, another Session cannot have a
-		 * live producer.  Staging entries there are crash orphans, not quota
-		 * reservations; remove and synchronize them before accounting. */
-		if (!current && evictable &&
-		    strncmp(de->d_name, ".pending-", 9) == 0) {
+	/* A lifetime-lock-proven inactive Session has no live producer.  Remove
+	 * all crash staging names as one durable pass before validating or
+	 * counting finals, because a crash after linkat can leave the two names
+	 * temporarily sharing an otherwise valid inode. */
+	if (!current && evictable) {
+		count = scandirat(dfd, ".", &names, pending_name, NULL);
+		if (count == -1)
+			goto done;
+		for (i = 0; i < count; i++) {
 			char order[FUGU_ARTIFACT_ID_BUFSZ + ORDER_PREFIX_LEN];
+			const char *id;
+			int have_final = 0;
 
-			if (unlinkat(dirfd(dir), de->d_name, 0) == -1 &&
-			    errno != ENOENT) {
-				closedir(dir);
-				return (-1);
+			name = names[i]->d_name;
+			id = name + 9;
+			if (final_name(id)) {
+				if (fstatat(dfd, id, &st,
+				    AT_SYMLINK_NOFOLLOW) == 0)
+					have_final = 1;
+				else if (errno != ENOENT)
+					goto done;
 			}
-			if (final_name(de->d_name + 9) &&
-			    order_name(de->d_name + 9, order, sizeof(order)) == 0 &&
-			    unlinkat(dirfd(dir), order, 0) == -1 && errno != ENOENT) {
-				closedir(dir);
-				return (-1);
-			}
-			dirty = 1;
-			continue;
+			if (unlinkat(dfd, name, 0) == -1 && errno != ENOENT)
+				goto done;
+			/* The order record belongs to an already-linked final when one
+			 * exists; pending-only crash state owns its order record. */
+			if (!have_final && final_name(id) &&
+			    order_name(id, order, sizeof(order)) == 0 &&
+			    unlinkat(dfd, order, 0) == -1 && errno != ENOENT)
+				goto done;
 		}
-		if ((size_t)snprintf(full, sizeof(full), "%s/%s", path,
-		    de->d_name) >= sizeof(full) || lstat(full, &st) == -1 ||
-		    !S_ISREG(st.st_mode) || st.st_size < 0) {
-			closedir(dir);
-			return (-1);
-		}
-		if ((uint64_t)st.st_size > UINT64_MAX - *global_total) {
-			closedir(dir);
-			return (-1);
-		}
+		free_dirents(names, count);
+		names = NULL;
+		if (fault_hit(ARTIFACT_STORE_FAULT_INACTIVE_CLEANUP_FSYNC) ||
+		    fsync(dfd) == -1)
+			goto done;
+	}
+	count = scandirat(dfd, ".", &names, artifact_name, NULL);
+	if (count == -1)
+		goto done;
+	for (i = 0; i < count; i++) {
+		int is_final;
+
+		name = names[i]->d_name;
+		is_final = final_name(name);
+		if (fstatat(dfd, name, &st,
+		    AT_SYMLINK_NOFOLLOW) == -1 || !S_ISREG(st.st_mode) ||
+		    st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 ||
+		    st.st_nlink != 1 || st.st_size < 0 ||
+		    (uint64_t)st.st_size > ARTIFACT_RAW_MAX)
+			goto done;
+		if ((uint64_t)st.st_size > UINT64_MAX - *global_total)
+			goto done;
 		*global_total += (uint64_t)st.st_size;
-		if (current)
+		if (current) {
+			if ((uint64_t)st.st_size > UINT64_MAX - *session_total)
+				goto done;
 			*session_total += (uint64_t)st.st_size;
-		if (!evictable || !final_name(de->d_name))
+		}
+		if (!evictable || !is_final)
 			continue;
+		if ((size_t)snprintf(full, sizeof(full), "%s/%s", path,
+		    name) >= sizeof(full))
+			goto done;
 		if (*nlist == *cap) {
 			*cap = *cap == 0 ? 16 : *cap * 2;
 			*list = xreallocarray(*list, *cap, sizeof(**list));
 		}
 		memset(&(*list)[*nlist], 0, sizeof(**list));
-		strlcpy((*list)[*nlist].path, full,
-		    sizeof((*list)[*nlist].path));
+		memcpy((*list)[*nlist].path, full, strlen(full) + 1);
 		(*list)[*nlist].size = st.st_size;
 		(*list)[*nlist].mtime = st.st_mtim;
 		{
-			int rc = read_order(dirfd(dir), de->d_name,
+			int order_rc = read_order(dfd, name,
 			    &(*list)[*nlist].sequence);
 
-			if (rc == -1) {
-				closedir(dir);
-				return (-1);
-			}
-			(*list)[*nlist].have_sequence = rc == 0;
+			if (order_rc == -1)
+				goto done;
+			(*list)[*nlist].have_sequence = order_rc == 0;
 		}
 		if ((size_t)snprintf((*list)[*nlist].order_path,
 		    sizeof((*list)[*nlist].order_path), "%s/%s%s", path,
-		    ORDER_PREFIX, de->d_name) >=
-		    sizeof((*list)[*nlist].order_path)) {
-			closedir(dir);
-			return (-1);
-		}
-		strlcpy((*list)[*nlist].dir_path, path,
-		    sizeof((*list)[*nlist].dir_path));
+		    ORDER_PREFIX, name) >=
+		    sizeof((*list)[*nlist].order_path))
+			goto done;
+		memcpy((*list)[*nlist].dir_path, path, strlen(path) + 1);
 		(*list)[*nlist].current = current;
 		(*nlist)++;
 	}
-	if (dirty && fsync(dirfd(dir)) == -1) {
-		closedir(dir);
-		return (-1);
-	}
-	closedir(dir);
-	return (0);
+	rc = 0;
+done:
+	if (names != NULL)
+		free_dirents(names, count);
+	close(dfd);
+	return (rc);
 }
 
 static int
@@ -539,18 +665,29 @@ eviction_sync(struct eviction *list, size_t nlist)
 static int
 other_session_lock(struct artifact_store *s, const char *session)
 {
-	char path[PATH_MAX];
+	struct stat st;
 	int dfd, fd;
 
-	if ((size_t)snprintf(path, sizeof(path), "%s/%s", s->root,
-	    session) >= sizeof(path) ||
-	    (dfd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC |
-	    O_NOFOLLOW)) == -1)
+	if (!session_valid(session) || (dfd = openat(s->root_fd, session,
+	    O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)) == -1)
 		return (-1);
-	fd = openat(dfd, ".session.lock", O_RDWR | O_CLOEXEC | O_NOFOLLOW);
+	if (fstat(dfd, &st) == -1 || !S_ISDIR(st.st_mode) ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0700) {
+		close(dfd);
+		return (-1);
+	}
+	fd = openat(dfd, ".session.lock",
+	    O_RDWR | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW);
 	close(dfd);
 	if (fd == -1)
 		return (-1);
+	if (fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 ||
+	    st.st_nlink != 1 || st.st_size != 0 ||
+	    lock_fd_normalize(fd) == -1) {
+		close(fd);
+		return (-1);
+	}
 	if (flock(fd, LOCK_EX | LOCK_NB) == -1) {
 		close(fd);
 		return (-1);
@@ -562,42 +699,37 @@ static int
 reserve_space(struct artifact_store *s)
 {
 	struct eviction *list = NULL;
-	struct dirent *de;
-	DIR *root;
+	struct dirent **names = NULL;
 	int *locks = NULL;
 	uint64_t session_total = 0, global_total = 0;
 	size_t nlist = 0, cap = 0, i, nlocks = 0;
-	int dfd = -1, lockfd, ok = -1, scan_ok = 0;
+	int count = 0, j, lockfd, ok = -1, scan_ok = 0;
 
 	if (flock(s->global_lock_fd, LOCK_EX) == -1)
 		return (-1);
 	if (scan_session(s, s->session, 1, 1, &session_total,
 	    &global_total, &list, &nlist, &cap) == -1)
 		goto done;
-	if ((dfd = open(s->root, O_RDONLY | O_DIRECTORY | O_CLOEXEC |
-	    O_NOFOLLOW)) == -1 || (root = fdopendir(dfd)) == NULL) {
-		if (dfd != -1)
-			close(dfd);
+	if ((count = scandirat(s->root_fd, ".", &names, session_name,
+	    NULL)) == -1)
 		goto done;
-	}
-	while ((de = readdir(root)) != NULL) {
-		if (strcmp(de->d_name, ".") == 0 ||
-		    strcmp(de->d_name, "..") == 0 ||
-		    strcmp(de->d_name, s->session) == 0 ||
-		    de->d_name[0] == '.')
+	for (j = 0; j < count; j++) {
+		const char *name = names[j]->d_name;
+
+		if (strcmp(name, s->session) == 0)
 			continue;
-		lockfd = other_session_lock(s, de->d_name);
+		lockfd = other_session_lock(s, name);
 		if (lockfd != -1) {
 			locks = xreallocarray(locks, nlocks + 1, sizeof(*locks));
 			locks[nlocks++] = lockfd;
 		}
-		if (scan_session(s, de->d_name, 0, lockfd != -1, &session_total,
+		if (scan_session(s, name, 0, lockfd != -1, &session_total,
 		    &global_total, &list, &nlist, &cap) == -1) {
-			closedir(root);
 			goto done;
 		}
 	}
-	closedir(root);
+	free_dirents(names, count);
+	names = NULL;
 	qsort(list, nlist, sizeof(*list), eviction_cmp);
 	/* Satisfy the per-Session bound from that Session's own oldest finals. */
 	for (i = 0; session_total >
@@ -633,6 +765,8 @@ done:
 	 * or quota lock is released, including partial-error paths. */
 	if (eviction_sync(list, nlist) == -1)
 		ok = -1;
+	if (names != NULL)
+		free_dirents(names, count);
 	free(list);
 	for (i = 0; i < nlocks; i++) {
 		(void)flock(locks[i], LOCK_UN);
@@ -651,9 +785,10 @@ artifact_store_turn_begin(struct artifact_store *s)
 {
 	struct artifact_pending *p;
 
-	if (s == NULL || s->turn_open)
+	if (s == NULL || s->phase != ARTIFACT_STORE_READY || s->turn_open ||
+	    s->turn_prepared)
 		return (-1);
-	for (p = s->pending; p != NULL; p = p->next)
+	LIST_FOREACH(p, &s->pending, entry)
 		if (p->turn_owned)
 			return (-1);
 	s->turn_open = 1;
@@ -684,7 +819,9 @@ artifact_store_begin(struct artifact_store *s, uint32_
 	int pipes[2], tries;
 
 	if (s == NULL || out == NULL || write_fd == NULL || stream == NULL ||
-	    !s->turn_open || correlation == 0 ||
+	    s->phase != ARTIFACT_STORE_READY || !s->turn_open ||
+	    s->turn_prepared ||
+	    correlation == 0 ||
 	    pending_find_correlation(s, correlation) != NULL ||
 	    (kind != ARTIFACT_BEGIN_FOREGROUND && kind != ARTIFACT_BEGIN_SPOOL))
 		return (-1);
@@ -723,8 +860,9 @@ artifact_store_begin(struct artifact_store *s, uint32_
 			continue;
 		if (errno != ENOENT)
 			break;
-		(void)snprintf(p->staging, sizeof(p->staging), ".pending-%s",
-		    p->id);
+		if ((size_t)snprintf(p->staging, sizeof(p->staging),
+		    ".pending-%s", p->id) >= sizeof(p->staging))
+			break;
 		p->file_fd = openat(s->session_fd, p->staging,
 		    O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
 		if (p->file_fd != -1 || errno != EEXIST)
@@ -756,10 +894,9 @@ artifact_store_begin(struct artifact_store *s, uint32_
 	}
 	(void)flock(s->global_lock_fd, LOCK_UN);
 	p->read_fd = pipes[0];
-	p->next = s->pending;
-	s->pending = p;
+	LIST_INSERT_HEAD(&s->pending, p, entry);
 	stream->status = ARTIFACT_STREAM_AVAILABLE;
-	strlcpy(stream->id, p->id, sizeof(stream->id));
+	memcpy(stream->id, p->id, sizeof(stream->id));
 	*out = p;
 	*write_fd = pipes[1];
 	return (0);
@@ -785,18 +922,37 @@ pending_order(struct artifact_pending *p)
 	struct stat st;
 	uint64_t sequence = 0;
 	ssize_t n;
-	int counter = -1, order = -1, rc = -1;
+	int counter = -1, counter_created = 0, order = -1, rc = -1;
 
 	if (fault_hit(ARTIFACT_STORE_FAULT_DRAIN_ORDER) ||
 	    order_name(p->id, name, sizeof(name)) == -1 ||
 	    flock(s->global_lock_fd, LOCK_EX) == -1)
 		return (-1);
 	counter = openat(s->root_fd, SEQUENCE_FILE,
-	    O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600);
-	if (counter == -1 || fchmod(counter, 0600) == -1 ||
-	    fstat(counter, &st) == -1 || !S_ISREG(st.st_mode) ||
+	    O_RDWR | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW);
+	if (counter == -1 && errno == ENOENT) {
+		counter = openat(s->root_fd, SEQUENCE_FILE,
+		    O_RDWR | O_NONBLOCK | O_CREAT | O_EXCL | O_CLOEXEC |
+		    O_NOFOLLOW, 0600);
+		if (counter != -1)
+			counter_created = 1;
+	}
+	/* Existing metadata is authenticated before any chmod or write.  A new
+	 * O_EXCL inode may be repaired after an inherited umask, then undergoes
+	 * the same complete authentication before its first write. */
+	if (counter == -1 || fstat(counter, &st) == -1 ||
+	    !S_ISREG(st.st_mode) || st.st_uid != geteuid() ||
+	    st.st_nlink != 1 ||
+	    (!counter_created && (st.st_mode & 0777) != 0600) ||
 	    (st.st_size != 0 && st.st_size != (off_t)sizeof(sequence)))
 		goto done;
+	if (counter_created && fchmod(counter, 0600) == -1)
+		goto done;
+	if (fstat(counter, &st) == -1 || !S_ISREG(st.st_mode) ||
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 ||
+	    st.st_nlink != 1 ||
+	    (st.st_size != 0 && st.st_size != (off_t)sizeof(sequence)))
+		goto done;
 	if (st.st_size != 0) {
 		do {
 			n = pread(counter, &sequence, sizeof(sequence), 0);
@@ -814,8 +970,12 @@ pending_order(struct artifact_pending *p)
 	    ftruncate(counter, sizeof(sequence)) == -1 || fsync(counter) == -1)
 		goto done;
 	order = openat(s->session_fd, name,
-	    O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
-	if (order == -1 || fchmod(order, 0600) == -1)
+	    O_WRONLY | O_NONBLOCK | O_CREAT | O_EXCL | O_CLOEXEC |
+	    O_NOFOLLOW, 0600);
+	if (order == -1 || fchmod(order, 0600) == -1 ||
+	    fstat(order, &st) == -1 ||
+	    !S_ISREG(st.st_mode) || st.st_uid != geteuid() ||
+	    (st.st_mode & 0777) != 0600 || st.st_nlink != 1 || st.st_size != 0)
 		goto done;
 	do {
 		n = write(order, &sequence, sizeof(sequence));
@@ -897,23 +1057,18 @@ artifact_pending_drain(struct artifact_pending *p,
 	p->terminal_done = 1;
 	p->terminal_status = terminal->status;
 	terminal->retained_bytes = p->retained;
-	strlcpy(terminal->id, p->id, sizeof(terminal->id));
+	memcpy(terminal->id, p->id, sizeof(terminal->id));
 	return (result);
 }
 
 static struct artifact_pending *
-pending_find(struct artifact_store *s, const char *id,
-    struct artifact_pending ***linkp)
+pending_find(struct artifact_store *s, const char *id)
 {
-	struct artifact_pending **link, *p;
+	struct artifact_pending *p;
 
-	for (link = &s->pending; (p = *link) != NULL; link = &p->next) {
-		if (strcmp(p->id, id) == 0) {
-			if (linkp != NULL)
-				*linkp = link;
+	LIST_FOREACH(p, &s->pending, entry)
+		if (strcmp(p->id, id) == 0)
 			return (p);
-		}
-	}
 	return (NULL);
 }
 
@@ -922,7 +1077,7 @@ pending_find_correlation(struct artifact_store *s, uin
 {
 	struct artifact_pending *p;
 
-	for (p = s->pending; p != NULL; p = p->next)
+	LIST_FOREACH(p, &s->pending, entry)
 		if (p->correlation == correlation)
 			return (p);
 	return (NULL);
@@ -930,12 +1085,12 @@ pending_find_correlation(struct artifact_store *s, uin
 
 static struct artifact_pending *
 pending_find_pair(struct artifact_store *s, uint32_t correlation,
-    const char *id, struct artifact_pending ***linkp)
+    const char *id)
 {
 	struct artifact_pending *p;
 
 	if (correlation == 0 ||
-	    (p = pending_find(s, id, linkp)) == NULL ||
+	    (p = pending_find(s, id)) == NULL ||
 	    p->correlation != correlation)
 		return (NULL);
 	return (p);
@@ -945,12 +1100,13 @@ int
 artifact_store_discard(struct artifact_store *s, uint32_t correlation,
     const char *id)
 {
-	struct artifact_pending **link, *p;
+	struct artifact_pending *p;
 
-	if (s == NULL || !artifact_id_wire_valid(id) ||
-	    (p = pending_find_pair(s, correlation, id, &link)) == NULL ||
+	if (s == NULL || s->phase != ARTIFACT_STORE_READY ||
+	    !artifact_id_wire_valid(id) ||
+	    (p = pending_find_pair(s, correlation, id)) == NULL ||
 	    !p->terminal_done ||
-	    p->prepared)
+	    p->final_linked || p->prepared)
 		return (-1);
 	if (p->file_fd != -1) {
 		close(p->file_fd);
@@ -961,7 +1117,7 @@ artifact_store_discard(struct artifact_store *s, uint3
 		return (-1);
 	if (order_unlink(s, p->id) == -1)
 		return (-1);
-	*link = p->next;
+	LIST_REMOVE(p, entry);
 	free(p);
 	return (0);
 }
@@ -972,12 +1128,13 @@ artifact_store_retain(struct artifact_store *s, uint32
 {
 	struct artifact_pending *p;
 
-	if (s == NULL || !s->turn_open || !artifact_id_wire_valid(id) ||
-	    (p = pending_find_pair(s, correlation, id, NULL)) == NULL ||
+	if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open ||
+	    !artifact_id_wire_valid(id) ||
+	    (p = pending_find_pair(s, correlation, id)) == NULL ||
 	    !p->terminal_done ||
 	    (p->terminal_status != ARTIFACT_STORE_OK &&
 	    p->terminal_status != ARTIFACT_STORE_CAP) || !p->background ||
-	    p->prepared)
+	    p->final_linked || p->prepared)
 		return (-1);
 	p->background = 0;
 	p->turn_owned = 1;
@@ -990,10 +1147,11 @@ artifact_store_detach(struct artifact_store *s, uint32
 {
 	struct artifact_pending *p;
 
-	if (s == NULL || !s->turn_open || !artifact_id_wire_valid(id) ||
-	    (p = pending_find_pair(s, correlation, id, NULL)) == NULL ||
+	if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open ||
+	    !artifact_id_wire_valid(id) ||
+	    (p = pending_find_pair(s, correlation, id)) == NULL ||
 	    !p->background ||
-	    !p->turn_owned || p->prepared)
+	    !p->turn_owned || p->final_linked || p->prepared)
 		return (-1);
 	p->turn_owned = 0;
 	return (0);
@@ -1004,9 +1162,10 @@ artifact_store_turn_prepare(struct artifact_store *s)
 {
 	struct artifact_pending *p;
 
-	if (s == NULL || !s->turn_open)
+	if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open ||
+	    s->turn_prepared)
 		return (-1);
-	for (p = s->pending; p != NULL; p = p->next) {
+	LIST_FOREACH(p, &s->pending, entry) {
 		if (p->terminal_done &&
 		    p->terminal_status == ARTIFACT_STORE_ERROR)
 			return (-1);
@@ -1022,52 +1181,62 @@ artifact_store_turn_prepare(struct artifact_store *s)
 		    p->terminal_status != ARTIFACT_STORE_CAP) || p->prepared)
 			return (-1);
 	}
-	for (p = s->pending; p != NULL; p = p->next) {
+	LIST_FOREACH(p, &s->pending, entry) {
 		if (p->background)
 			continue;
 		/* linkat is the no-replace publication primitive: renameat would
 		 * silently overwrite a final and let an expired id alias new bytes. */
 		if (fsync(p->file_fd) == -1 ||
 		    linkat(s->session_fd, p->staging, s->session_fd, p->id, 0) ==
-		    -1 || unlinkat(s->session_fd, p->staging, 0) == -1)
+		    -1)
 			return (-1);
+		p->final_linked = 1;
+		if (fault_hit(ARTIFACT_STORE_FAULT_PREPARE_STAGING_UNLINK) ||
+		    unlinkat(s->session_fd, p->staging, 0) == -1)
+			return (-1);
 		p->prepared = 1;
 	}
-	return (fsync(s->session_fd));
+	if (fsync(s->session_fd) == -1)
+		return (-1);
+	s->turn_prepared = 1;
+	return (0);
 }
 
 int
 artifact_store_turn_commit(struct artifact_store *s)
 {
-	struct artifact_pending **link, *p;
+	struct artifact_pending *p, *next;
 
-	if (s == NULL || !s->turn_open)
+	if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open ||
+	    !s->turn_prepared)
 		return (-1);
-	for (link = &s->pending; (p = *link) != NULL;) {
+	for (p = LIST_FIRST(&s->pending); p != NULL; p = next) {
+		next = LIST_NEXT(p, entry);
 		if (p->background) {
 			p->turn_owned = 0;
-			link = &p->next;
 			continue;
 		}
 		if (!p->prepared || known_add(s, p->id, p->retained) == -1)
 			return (-1);
 		close(p->file_fd);
-		*link = p->next;
+		LIST_REMOVE(p, entry);
 		free(p);
 	}
 	s->turn_open = 0;
+	s->turn_prepared = 0;
 	return (0);
 }
 
 int
 artifact_store_turn_abort(struct artifact_store *s)
 {
-	struct artifact_pending **link, *p;
+	struct artifact_pending *p, *next;
 	int failed = 0, dirty = 0;
 
-	if (s == NULL || !s->turn_open)
+	if (s == NULL || s->phase != ARTIFACT_STORE_READY || !s->turn_open)
 		return (-1);
-	for (p = s->pending; p != NULL; p = p->next) {
+	s->turn_prepared = 0;
+	LIST_FOREACH(p, &s->pending, entry) {
 		if (!p->turn_owned)
 			continue;
 		if (p->read_fd != -1) {
@@ -1078,15 +1247,19 @@ artifact_store_turn_abort(struct artifact_store *s)
 			close(p->file_fd);
 			p->file_fd = -1;
 		}
-		if (fault_hit(p->prepared ?
-		    ARTIFACT_STORE_FAULT_ABORT_FINAL_UNLINK :
-		    ARTIFACT_STORE_FAULT_ABORT_STAGING_UNLINK) ||
-		    (unlinkat(s->session_fd,
-		    p->prepared ? p->id : p->staging, 0) == -1 &&
+		if (fault_hit(ARTIFACT_STORE_FAULT_ABORT_STAGING_UNLINK) ||
+		    (unlinkat(s->session_fd, p->staging, 0) == -1 &&
 		    errno != ENOENT))
 			failed = 1;
 		else
 			dirty = 1;
+		if (p->final_linked &&
+		    (fault_hit(ARTIFACT_STORE_FAULT_ABORT_FINAL_UNLINK) ||
+		    (unlinkat(s->session_fd, p->id, 0) == -1 &&
+		    errno != ENOENT)))
+			failed = 1;
+		else if (p->final_linked)
+			dirty = 1;
 		if (fault_hit(ARTIFACT_STORE_FAULT_ABORT_ORDER_UNLINK) ||
 		    order_unlink(s, p->id) == -1)
 			failed = 1;
@@ -1098,12 +1271,11 @@ artifact_store_turn_abort(struct artifact_store *s)
 		failed = 1;
 	if (failed)
 		return (-1);
-	for (link = &s->pending; (p = *link) != NULL;) {
-		if (!p->turn_owned) {
-			link = &p->next;
+	for (p = LIST_FIRST(&s->pending); p != NULL; p = next) {
+		next = LIST_NEXT(p, entry);
+		if (!p->turn_owned)
 			continue;
-		}
-		*link = p->next;
+		LIST_REMOVE(p, entry);
 		free(p);
 	}
 	s->turn_open = 0;
@@ -1122,9 +1294,12 @@ artifact_store_read(struct artifact_store *s,
 	int fd = -1;
 	int pending = 0;
 
+	if (s == NULL || out == NULL || result == NULL ||
+	    s->phase != ARTIFACT_STORE_READY)
+		return (-1);
 	buf_reset(out);
 	memset(result, 0, sizeof(*result));
-	if (s == NULL || req == NULL || !artifact_id_wire_valid(req->id)) {
+	if (req == NULL || !artifact_id_wire_valid(req->id)) {
 		result->status = ARTIFACT_READ_MALFORMED;
 		return (0);
 	}
@@ -1132,7 +1307,7 @@ artifact_store_read(struct artifact_store *s,
 		result->status = ARTIFACT_READ_RANGE;
 		return (0);
 	}
-	if ((p = pending_find(s, req->id, NULL)) != NULL) {
+	if ((p = pending_find(s, req->id)) != NULL) {
 		fd = p->file_fd;
 		result->total_bytes = p->retained;
 		result->status = ARTIFACT_READ_PENDING;
@@ -1140,7 +1315,7 @@ artifact_store_read(struct artifact_store *s,
 	} else if ((known = known_find(s, req->id)) != NULL) {
 		result->total_bytes = known->retained;
 		fd = openat(s->session_fd, req->id,
-		    O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
+		    O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW);
 		if (fd == -1 && errno == ENOENT) {
 			result->status = ARTIFACT_READ_EXPIRED;
 			return (0);
@@ -1151,7 +1326,8 @@ artifact_store_read(struct artifact_store *s,
 		return (0);
 	}
 	if (fd == -1 || fstat(fd, &st) == -1 || !S_ISREG(st.st_mode) ||
-	    st.st_size < 0 || (pending ?
+	    st.st_uid != geteuid() || (st.st_mode & 0777) != 0600 ||
+	    st.st_nlink != 1 || st.st_size < 0 || (pending ?
 	    (uint64_t)st.st_size < result->total_bytes :
 	    (uint64_t)st.st_size != result->total_bytes)) {
 		result->status = ARTIFACT_READ_ERROR;
blob - e0eb536cbc2b88f91b61d63beeed573ae12392ab
blob + 53ad88a2dd0728d10fef8858ecadf4dcace41e3d
--- src/fugu-artifact/store.h
+++ src/fugu-artifact/store.h
@@ -59,11 +59,14 @@ enum artifact_store_test_fault {
 	ARTIFACT_STORE_FAULT_DRAIN_FTRUNCATE,
 	ARTIFACT_STORE_FAULT_DRAIN_FUTIMENS,
 	ARTIFACT_STORE_FAULT_DRAIN_ORDER,
+	ARTIFACT_STORE_FAULT_RECONCILE_FSYNC,
+	ARTIFACT_STORE_FAULT_PREPARE_STAGING_UNLINK,
 	ARTIFACT_STORE_FAULT_ABORT_STAGING_UNLINK,
 	ARTIFACT_STORE_FAULT_ABORT_FINAL_UNLINK,
 	ARTIFACT_STORE_FAULT_ABORT_ORDER_UNLINK,
 	ARTIFACT_STORE_FAULT_ABORT_FSYNC,
 	ARTIFACT_STORE_FAULT_DISCARD_UNLINK,
+	ARTIFACT_STORE_FAULT_INACTIVE_CLEANUP_FSYNC,
 	ARTIFACT_STORE_FAULT_EVICTION_FSYNC
 };
 
blob - 25d5a1715dc1a828482e9a376c84e3e696389500
blob + d20aac06cfa37b15618a2fb9c3355db0abf7cb9f
--- src/fugu-tool/Makefile
+++ src/fugu-tool/Makefile
@@ -3,8 +3,8 @@ SRCS=	main.c agent.c agent_boot.c tools.c job.c superv
 	anthropic_req.c claude_sub_req.c claude_sub_profile.c openai_req.c \
 	codex_sub_req.c codex_sub_profile.c \
 	turn_mechanics.c generation.c msg.c provider_state.c json.c \
-	artifact_result.c artifact_capture.c effort.c buf.c diff.c log.c \
-	xmalloc.c
+	artifact_capture.c artifact_pipe.c artifact_scope.c \
+	artifact_result.c effort.c buf.c diff.c log.c xmalloc.c
 BINDIR=	${LIBEXECDIR}/fugu
 MAN=
 
blob - 94d6edd0df8f48460e37801d9c44410c5fe00091
blob + ba537a15eb22ee38f21810488368ceb153bbbf93
--- src/fugu-tool/job.c
+++ src/fugu-tool/job.c
@@ -15,11 +15,14 @@
  */
 
 #include <sys/types.h>
+#include <sys/ioctl.h>
+#include <sys/queue.h>
 #include <sys/wait.h>
 
 #include <errno.h>
 #include <fcntl.h>
 #include <limits.h>
+#include <poll.h>
 #include <signal.h>
 #include <stdint.h>
 #include <stdio.h>
@@ -40,7 +43,26 @@
 #define JOB_PUMP_TOTAL		(1024 * 1024)
 #define JOB_WRITE_PENDING_MAX	(64 * 1024)
 #define DEFAULT_CAP	4		/* max_bg_jobs default */
+#define JOB_COMPLETED_RECORD_MAX 64	/* separate from running-job cap */
+#define JOB_TOMBSTONE_MAX	512	/* exact delayed-control keys */
+#define JOB_OWNERSHIP_MAX	1024	/* all unresolved job/spool keys */
 
+struct job_artifact_ref {
+	LIST_ENTRY(job_artifact_ref) entry;
+	char id[ARTIFACT_ID_HEX_LEN + 1];
+};
+LIST_HEAD(job_artifact_ref_list, job_artifact_ref);
+
+struct job_artifact_tombstone {
+	LIST_ENTRY(job_artifact_tombstone) entry;
+	int		 job_id;
+	uint32_t	 correlation;
+	int		 drop_pending;
+	int		 drop_sent;
+	char		 id[ARTIFACT_ID_HEX_LEN + 1];
+};
+LIST_HEAD(job_artifact_tombstone_list, job_artifact_tombstone);
+
 struct job {
 	struct job	*next;
 	int		 id;
@@ -52,7 +74,10 @@ struct job {
 	int		 artifact_fd;	/* current spool write end, nonblocking */
 	int		 artifact_managed;
 	int		 artifact_available;
+	int		 artifact_settling;
+	uint32_t	 artifact_correlation;
 	char		 artifact_id[ARTIFACT_ID_HEX_LEN + 1];
+	struct job_artifact_ref_list artifact_history;
 	struct artifact_capture capture;	/* bounded current segment preview */
 	struct buf	 pending_write;	/* bounded partial spool write */
 	int		 cap_reached;
@@ -61,26 +86,317 @@ struct job {
 	int		 exited;
 	int		 status;
 	int		 pending;	/* spawned by the open, uncommitted Turn */
+	int		 reported_terminal;
+#ifdef JOB_TESTING
+	size_t		 test_pump_bytes;
+#endif
 };
 
 struct joblist {
 	struct job	*head;
+	struct job_artifact_tombstone_list tombstones;
 	int64_t		 next_id;
 	int		 cap;
+	size_t		 record_limit;
+	size_t		 tombstone_count;
+	size_t		 tombstone_limit;
+	size_t		 ownership_limit;
 	int		 turn_open;
 	int		 pump_cursor;	/* last id visited by round-robin pump */
+#ifdef JOB_TESTING
+	size_t		 test_pump_total;
+#endif
 };
 
 static void job_stop(struct job *);
+static struct job *job_find(struct joblist *, int);
+static void job_prune_reported(struct joblist *);
+static size_t job_ownership_count(struct joblist *);
 
+#ifdef JOB_TESTING
+static int test_backpressure_at_boundary;
+static size_t test_pump_per_job = JOB_PUMP_PER_JOB;
+static size_t test_pump_total = JOB_PUMP_TOTAL;
+
+void
+job_test_backpressure_at_boundary(void)
+{
+	test_backpressure_at_boundary = 1;
+}
+
+int
+job_test_boundary_pending(struct joblist *jl, int id)
+{
+	struct job *j = job_find(jl, id);
+
+	return (j != NULL && j->capture.retained == ARTIFACT_RAW_MAX &&
+	    j->pending_write.len > 0);
+}
+
+int
+job_test_storage_unavailable(struct joblist *jl, int id)
+{
+	struct job *j = job_find(jl, id);
+
+	return (j != NULL && j->storage_unavailable);
+}
+
+size_t
+job_test_pending_bytes(struct joblist *jl, int id)
+{
+	struct job *j = job_find(jl, id);
+
+	return (j != NULL ? j->pending_write.len : 0);
+}
+
+size_t
+job_test_output_bytes(struct joblist *jl, int id)
+{
+	struct job *j = job_find(jl, id);
+	int bytes = 0;
+
+	if (j == NULL || j->fd == -1 || ioctl(j->fd, FIONREAD, &bytes) == -1 ||
+	    bytes < 0)
+		return (0);
+	return ((size_t)bytes);
+}
+
+void
+job_test_set_pump_limits(size_t per_job, size_t total)
+{
+	test_pump_per_job = per_job;
+	test_pump_total = total;
+}
+
+void
+job_test_reset_pump_limits(void)
+{
+	test_pump_per_job = JOB_PUMP_PER_JOB;
+	test_pump_total = JOB_PUMP_TOTAL;
+}
+
+size_t
+job_test_pump_per_job_limit(void)
+{
+	return (JOB_PUMP_PER_JOB);
+}
+
+size_t
+job_test_pump_total_limit(void)
+{
+	return (JOB_PUMP_TOTAL);
+}
+
+size_t
+job_test_last_pump_total(struct joblist *jl)
+{
+	return (jl->test_pump_total);
+}
+
+size_t
+job_test_last_pump_bytes(struct joblist *jl, int id)
+{
+	struct job *j = job_find(jl, id);
+
+	return (j != NULL ? j->test_pump_bytes : 0);
+}
+
+int
+job_test_state(struct joblist *jl, int id, struct job_test_state *state)
+{
+	struct job *j = job_find(jl, id);
+
+	if (j == NULL || state == NULL)
+		return (-1);
+	memset(state, 0, sizeof(*state));
+	state->observed = j->capture.observed;
+	state->retained = j->capture.retained;
+	state->pending_bytes = j->pending_write.len;
+	state->cap_reached = j->cap_reached;
+	state->exited = j->exited;
+	state->producer_open = j->fd != -1;
+	state->artifact_open = j->artifact_fd != -1;
+	state->storage_error = j->storage_error;
+	state->storage_unavailable = j->storage_unavailable;
+	return (0);
+}
+
+void
+job_test_set_record_limit(struct joblist *jl, size_t limit)
+{
+	jl->record_limit = limit;
+}
+
+void
+job_test_set_tombstone_limit(struct joblist *jl, size_t limit)
+{
+	jl->tombstone_limit = limit;
+}
+
+size_t
+job_test_record_count(struct joblist *jl)
+{
+	struct job *j;
+	size_t n = 0;
+
+	for (j = jl->head; j != NULL; j = j->next)
+		n++;
+	return (n);
+}
+
+size_t
+job_test_tombstone_count(struct joblist *jl)
+{
+	return (jl->tombstone_count);
+}
+
+void
+job_test_set_ownership_limit(struct joblist *jl, size_t limit)
+{
+	jl->ownership_limit = limit;
+}
+
+size_t
+job_test_ownership_count(struct joblist *jl)
+{
+	return (job_ownership_count(jl));
+}
+#endif
+
+static void
+job_history_free(struct job *j)
+{
+	struct job_artifact_ref *ref;
+
+	while ((ref = LIST_FIRST(&j->artifact_history)) != NULL) {
+		LIST_REMOVE(ref, entry);
+		free(ref);
+	}
+}
+
+static int
+job_history_has(const struct job *j, const char *id)
+{
+	const struct job_artifact_ref *ref;
+
+	LIST_FOREACH(ref, &j->artifact_history, entry)
+		if (strcmp(ref->id, id) == 0)
+			return (1);
+	return (0);
+}
+
+static int
+job_history_add(struct job *j, const char *id)
+{
+	struct job_artifact_ref *ref;
+
+	if (!artifact_id_wire_valid(id) || job_history_has(j, id))
+		return (-1);
+	ref = xcalloc(1, sizeof(*ref));
+	strlcpy(ref->id, id, sizeof(ref->id));
+	LIST_INSERT_HEAD(&j->artifact_history, ref, entry);
+	return (0);
+}
+
+static int
+job_history_remove(struct job *j, const char *id)
+{
+	struct job_artifact_ref *ref;
+
+	LIST_FOREACH(ref, &j->artifact_history, entry) {
+		if (strcmp(ref->id, id) != 0)
+			continue;
+		LIST_REMOVE(ref, entry);
+		free(ref);
+		return (0);
+	}
+	return (-1);
+}
+
+static size_t
+job_history_count(const struct job *j)
+{
+	const struct job_artifact_ref *ref;
+	size_t n = 0;
+
+	LIST_FOREACH(ref, &j->artifact_history, entry)
+		n++;
+	return (n);
+}
+
+static int
+job_artifact_id_zero(const char id[ARTIFACT_ID_HEX_LEN + 1])
+{
+	static const char zero[ARTIFACT_ID_HEX_LEN + 1];
+
+	return (memcmp(id, zero, sizeof(zero)) == 0);
+}
+
+static struct job_artifact_tombstone *
+job_tombstone_find(struct joblist *jl, int job_id, const char *id)
+{
+	struct job_artifact_tombstone *tomb;
+
+	LIST_FOREACH(tomb, &jl->tombstones, entry)
+		if (tomb->job_id == job_id && strcmp(tomb->id, id) == 0)
+			return (tomb);
+	return (NULL);
+}
+
+static int
+job_tombstone_add(struct joblist *jl, int job_id, const char *id,
+    uint32_t correlation, int drop_pending)
+{
+	struct job_artifact_tombstone *tomb;
+
+	if ((!artifact_id_wire_valid(id) &&
+	    !(drop_pending && job_artifact_id_zero(id))) ||
+	    jl->tombstone_count >=
+	    jl->tombstone_limit || job_tombstone_find(jl, job_id, id) != NULL ||
+	    (drop_pending && correlation == 0))
+		return (-1);
+	tomb = xcalloc(1, sizeof(*tomb));
+	tomb->job_id = job_id;
+	tomb->correlation = correlation;
+	tomb->drop_pending = drop_pending;
+	strlcpy(tomb->id, id, sizeof(tomb->id));
+	LIST_INSERT_HEAD(&jl->tombstones, tomb, entry);
+	jl->tombstone_count++;
+	return (0);
+}
+
+static void
+job_tombstone_remove(struct joblist *jl, struct job_artifact_tombstone *tomb)
+{
+	LIST_REMOVE(tomb, entry);
+	free(tomb);
+	jl->tombstone_count--;
+}
+
+static void
+job_tombstones_remove_job(struct joblist *jl, int job_id)
+{
+	struct job_artifact_tombstone *tomb, *next;
+
+	for (tomb = LIST_FIRST(&jl->tombstones); tomb != NULL; tomb = next) {
+		next = LIST_NEXT(tomb, entry);
+		if (tomb->job_id == job_id)
+			job_tombstone_remove(jl, tomb);
+	}
+}
+
 struct joblist *
 job_list_new(void)
 {
 	struct joblist	*jl;
 
 	jl = xcalloc(1, sizeof(*jl));
+	LIST_INIT(&jl->tombstones);
 	jl->next_id = 1;
 	jl->cap = DEFAULT_CAP;
+	jl->record_limit = DEFAULT_CAP + JOB_COMPLETED_RECORD_MAX;
+	jl->tombstone_limit = JOB_TOMBSTONE_MAX;
+	jl->ownership_limit = JOB_OWNERSHIP_MAX;
 	return (jl);
 }
 
@@ -88,8 +404,17 @@ void
 job_set_cap(struct joblist *jl, int cap)
 {
 	jl->cap = cap;
+	jl->record_limit = (size_t)cap + JOB_COMPLETED_RECORD_MAX;
 }
 
+void
+job_selftest_set_ownership_limit(struct joblist *jl, size_t limit)
+{
+	if (jl == NULL || limit == 0 || limit > JOB_OWNERSHIP_MAX)
+		abort();
+	jl->ownership_limit = limit;
+}
+
 static struct job *
 job_find(struct joblist *jl, int id)
 {
@@ -110,7 +435,152 @@ job_spool_close(struct job *j)
 	}
 }
 
+static size_t
+job_record_count(struct joblist *jl)
+{
+	struct job *j;
+	size_t n = 0;
+
+	for (j = jl->head; j != NULL; j = j->next)
+		n++;
+	return (n);
+}
+
+static size_t
+job_ownership_count(struct joblist *jl)
+{
+	struct job *j;
+	size_t n = jl->tombstone_count;
+
+	for (j = jl->head; j != NULL; j = j->next) {
+		n += job_history_count(j);
+		if (j->artifact_managed)
+			n++;
+	}
+	return (n);
+}
+
 static void
+job_destroy(struct job *j)
+{
+	job_stop(j);
+	if (j->fd != -1)
+		close(j->fd);
+	if (j->life_fd != -1)
+		close(j->life_fd);
+	if (j->status_fd != -1)
+		close(j->status_fd);
+	job_spool_close(j);
+	artifact_capture_free(&j->capture);
+	buf_free(&j->pending_write);
+	job_history_free(j);
+	free(j);
+}
+
+static int
+job_archive_possible(struct joblist *jl, const struct job *j,
+    int drop_current)
+{
+	const struct job_artifact_ref *ref;
+	size_t needed = job_history_count(j) + (drop_current ? 1 : 0);
+
+	if (jl->tombstone_count > jl->tombstone_limit ||
+	    needed > jl->tombstone_limit - jl->tombstone_count)
+		return (0);
+	LIST_FOREACH(ref, &j->artifact_history, entry)
+		if (!artifact_id_wire_valid(ref->id) ||
+		    job_tombstone_find(jl, j->id, ref->id) != NULL)
+			return (0);
+	if (drop_current && (!j->artifact_managed || j->artifact_correlation == 0 ||
+	    (j->artifact_available ? !artifact_id_wire_valid(j->artifact_id) :
+	    !job_artifact_id_zero(j->artifact_id)) ||
+	    job_tombstone_find(jl, j->id, j->artifact_id) != NULL))
+		return (0);
+	return (1);
+}
+
+static int
+job_archive(struct joblist *jl, struct job **link, int drop_current)
+{
+	struct job_artifact_ref *ref;
+	struct job *j = *link;
+
+	if (!job_archive_possible(jl, j, drop_current))
+		return (0);
+	LIST_FOREACH(ref, &j->artifact_history, entry)
+		if (job_tombstone_add(jl, j->id, ref->id, 0, 0) == -1)
+			abort();
+	if (drop_current && job_tombstone_add(jl, j->id, j->artifact_id,
+	    j->artifact_correlation, 1) == -1)
+		abort();
+	*link = j->next;
+	job_destroy(j);
+	return (1);
+}
+
+static void
+job_prune_reported(struct joblist *jl)
+{
+	struct job **link, *j;
+
+	for (link = &jl->head; (j = *link) != NULL; ) {
+		if (!j->pending && j->reported_terminal &&
+		    !j->artifact_available &&
+		    job_archive(jl, link, 0))
+			continue;
+		link = &j->next;
+	}
+}
+
+static int
+job_prune_completed_one(struct joblist *jl)
+{
+	struct job **link, **oldest_link = NULL, *j;
+
+	for (link = &jl->head; (j = *link) != NULL; link = &j->next) {
+		int drop_current = j->artifact_managed;
+
+		if (j->pending || j->artifact_settling || !j->exited || j->fd != -1 ||
+		    j->artifact_fd != -1 || j->pending_write.len != 0 ||
+		    !job_archive_possible(jl, j, drop_current))
+			continue;
+		if (oldest_link == NULL || j->id < (*oldest_link)->id)
+			oldest_link = link;
+	}
+	if (oldest_link == NULL)
+		return (0);
+	return (job_archive(jl, oldest_link,
+	    (*oldest_link)->artifact_managed));
+}
+
+int
+job_selftest_archive_completed(struct joblist *jl)
+{
+	struct job *j;
+
+	if (jl == NULL)
+		abort();
+	for (j = jl->head; j != NULL; j = j->next) {
+		if (j->pending || j->artifact_settling || j->fd != -1 ||
+		    j->artifact_fd != -1 || j->pending_write.len != 0)
+			continue;
+		job_stop(j);
+		break;
+	}
+	return (job_prune_completed_one(jl));
+}
+
+static int
+job_make_record_room(struct joblist *jl)
+{
+	job_prune_reported(jl);
+	while (job_record_count(jl) >= jl->record_limit)
+		if (!job_prune_completed_one(jl))
+			return (-1);
+	return (0);
+}
+
+static void
 job_fail_segment(struct job *j, int storage_error, int unavailable)
 {
 	if (storage_error)
@@ -127,6 +597,18 @@ job_fail_segment(struct job *j, int storage_error, int
 	job_spool_close(j);
 }
 
+static void
+job_cap_segment(struct job *j)
+{
+	j->cap_reached = 1;
+	if (!j->exited)
+		(void)killpg(j->pgid, SIGKILL);
+	if (j->fd != -1) {
+		close(j->fd);
+		j->fd = -1;
+	}
+}
+
 /* Flush the bounded partial write before reading another producer byte.
  * 1 means empty, 0 means pipe backpressure, -1 means storage failure. */
 static int
@@ -135,6 +617,14 @@ job_flush(struct job *j)
 	ssize_t n;
 
 	while (j->pending_write.len > 0) {
+#ifdef JOB_TESTING
+		if (test_backpressure_at_boundary &&
+		    j->capture.retained == ARTIFACT_RAW_MAX) {
+			test_backpressure_at_boundary = 0;
+			errno = EAGAIN;
+			return (0);
+		}
+#endif
 		n = write(j->artifact_fd, j->pending_write.data,
 		    j->pending_write.len);
 		if (n > 0) {
@@ -172,6 +662,11 @@ job_drain(struct job *j, size_t budget)
 		want = sizeof(chunk);
 		if (want > budget - drained)
 			want = budget - drained;
+		want = artifact_capture_read_limit(&j->capture, want);
+		if (want == 0) {
+			job_cap_segment(j);
+			break;
+		}
 		n = read(j->fd, chunk, want);
 		if (n == -1) {
 			if (errno == EINTR)
@@ -191,6 +686,8 @@ job_drain(struct job *j, size_t budget)
 			job_fail_segment(j, 1, 0);
 			break;
 		}
+		if (capture_rc == 1)
+			job_cap_segment(j);
 		keep = (size_t)j->capture.retained - before;
 		if (j->artifact_available && keep > 0) {
 			if (j->pending_write.len > JOB_WRITE_PENDING_MAX - keep) {
@@ -208,8 +705,8 @@ job_drain(struct job *j, size_t budget)
 			break;
 		}
 		if (capture_rc == 1) {
-			j->cap_reached = 1;
-			job_fail_segment(j, 0, 0);
+			if (j->pending_write.len == 0)
+				job_spool_close(j);
 			break;
 		}
 	}
@@ -281,11 +778,24 @@ job_pump(struct joblist *jl)
 {
 	struct job	*j, *candidate;
 	size_t		 total = 0;
+	size_t		 per_job_limit = JOB_PUMP_PER_JOB;
+	size_t		 total_limit = JOB_PUMP_TOTAL;
 	int		 cursor = jl->pump_cursor, visits = 0, count = 0;
 
-	for (j = jl->head; j != NULL; j = j->next)
+	for (j = jl->head; j != NULL; j = j->next) {
 		count++;
-	while (visits++ < count && total < JOB_PUMP_TOTAL) {
+#ifdef JOB_TESTING
+		j->test_pump_bytes = 0;
+#endif
+	}
+#ifdef JOB_TESTING
+	per_job_limit = test_pump_per_job;
+	total_limit = test_pump_total;
+	jl->test_pump_total = 0;
+#endif
+	while (visits++ < count && total < total_limit) {
+		size_t drained;
+
 		candidate = NULL;
 		for (j = jl->head; j != NULL; j = j->next)
 			if (j->id > cursor &&
@@ -299,13 +809,20 @@ job_pump(struct joblist *jl)
 		}
 		if (candidate == NULL)
 			break;
-		total += job_drain(candidate,
-		    JOB_PUMP_TOTAL - total < JOB_PUMP_PER_JOB ?
-		    JOB_PUMP_TOTAL - total : JOB_PUMP_PER_JOB);
+		drained = job_drain(candidate,
+		    total_limit - total < per_job_limit ?
+		    total_limit - total : per_job_limit);
+		total += drained;
+#ifdef JOB_TESTING
+		candidate->test_pump_bytes = drained;
+#endif
 		job_reap(candidate);
 		cursor = candidate->id;
 		jl->pump_cursor = cursor;
 	}
+#ifdef JOB_TESTING
+	jl->test_pump_total = total;
+#endif
 }
 
 static int
@@ -315,16 +832,24 @@ job_adopt_stream(struct job *j, struct tool_ctx *ctx)
 
 	j->artifact_managed = ctx->artifact_mode;
 	j->artifact_available = ctx->artifact_mode && ctx->artifact_available;
+	j->artifact_settling = 0;
+	j->artifact_correlation = ctx->artifact_mode ?
+	    ctx->artifact_correlation : 0;
 	j->artifact_fd = -1;
+	if (ctx->artifact_mode && j->artifact_correlation == 0)
+		return (-1);
 	if (j->artifact_available) {
-		if ((flags = fcntl(ctx->artifact_fd, F_GETFL)) == -1 ||
+		if (!artifact_id_wire_valid(ctx->artifact_id) ||
+		    job_history_has(j, ctx->artifact_id) ||
+		    (flags = fcntl(ctx->artifact_fd, F_GETFL)) == -1 ||
 		    fcntl(ctx->artifact_fd, F_SETFL, flags | O_NONBLOCK) == -1)
 			return (-1);
 		j->artifact_fd = ctx->artifact_fd;
 		ctx->artifact_fd = -1;
 		strlcpy(j->artifact_id, ctx->artifact_id,
 		    sizeof(j->artifact_id));
-	}
+	} else
+		memset(j->artifact_id, 0, sizeof(j->artifact_id));
 	if (ctx->artifact_mode)
 		ctx->artifact_spool_adopted = 1;
 	return (0);
@@ -373,6 +898,21 @@ job_start(struct joblist *jl, struct tool_ctx *ctx, co
 		free(cmd);
 		return;
 	}
+	if (job_make_record_room(jl) == -1) {
+		buf_addstr(out, "shell_bg: background-job settlement backlog; "
+		    "poll an existing completed job first");
+		*is_error = 1;
+		free(cmd);
+		return;
+	}
+	if (ctx->artifact_mode &&
+	    job_ownership_count(jl) >= jl->ownership_limit) {
+		buf_addstr(out, "shell_bg: Artifact settlement backlog; "
+		    "settle an earlier background spool first");
+		*is_error = 1;
+		free(cmd);
+		return;
+	}
 	if (pipe(pipefd) == -1) {
 		buf_addf(out, "shell_bg: pipe: %s", strerror(errno));
 		*is_error = 1;
@@ -405,6 +945,7 @@ job_start(struct joblist *jl, struct tool_ctx *ctx, co
 	free(cmd);
 
 	j = xcalloc(1, sizeof(*j));
+	LIST_INIT(&j->artifact_history);
 	j->id = (int)jl->next_id++;
 	j->pid = sp.pid;
 	j->pgid = sp.pgid;
@@ -430,6 +971,7 @@ job_start(struct joblist *jl, struct tool_ctx *ctx, co
 	}
 	j->next = jl->head;
 	jl->head = j;
+	ctx->artifact_job_id = j->id;
 
 	buf_addf(out, "started background job %d", j->id);
 }
@@ -468,34 +1010,118 @@ job_stop(struct job *j)
 }
 
 static int
-job_flush_blocking(struct job *j)
+job_flush_settle(struct job *j)
 {
-	int flags;
+	struct pollfd pfd;
+	int rc;
 
 	if (!j->artifact_available || j->artifact_fd == -1)
 		return (0);
-	if ((flags = fcntl(j->artifact_fd, F_GETFL)) == -1 ||
-	    fcntl(j->artifact_fd, F_SETFL, flags & ~O_NONBLOCK) == -1) {
-		job_fail_segment(j, 1, 0);
-		return (-1);
-	}
 	while (j->pending_write.len > 0) {
-		ssize_t n = write(j->artifact_fd, j->pending_write.data,
-		    j->pending_write.len);
-
-		if (n > 0) {
-			buf_drain(&j->pending_write, (size_t)n);
-			continue;
+		if (tool_cancel_requested())
+			return (1);
+		rc = job_flush(j);
+		if (rc == 1)
+			return (0);
+		if (rc == -1)
+			return (-1);
+		pfd.fd = j->artifact_fd;
+		pfd.events = POLLOUT;
+		pfd.revents = 0;
+		rc = poll(&pfd, 1, 100);
+		if (rc == -1) {
+			if (errno == EINTR)
+				continue;
+			goto fail;
 		}
-		if (n == -1 && errno == EINTR)
-			continue;
+		if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL))
+			goto fail;
+	}
+	return (0);
+fail:
+	job_fail_segment(j, 1, 0);
+	return (-1);
+}
+
+/* Settle the old spool while consuming no more than the single entry
+ * snapshot.  Cancellation leaves both producer and spool ownership intact. */
+static int
+job_freeze_snapshot(struct job *j, size_t snapshot)
+{
+	size_t drained = 0, n;
+	int rc;
+
+	for (;;) {
+		rc = job_flush_settle(j);
+		if (rc != 0)
+			return (rc);
+		if (drained == snapshot || j->fd == -1 || j->cap_reached ||
+		    j->storage_error || j->storage_unavailable)
+			return (0);
+		n = job_drain(j, snapshot - drained);
+		drained += n;
+		if (n == 0 && j->pending_write.len == 0)
+			return (0);
+	}
+}
+
+/* Freeze only bytes already queued by the producer when shell_output entered.
+ * The per-job pump budget is also the synchronous poll budget; bytes queued
+ * later remain in the producer pipe for the replacement segment. */
+static int
+job_poll_snapshot(struct job *j, size_t *snapshot)
+{
+	int bytes = 0;
+
+	*snapshot = 0;
+	if (j->fd == -1)
+		return (0);
+	if (ioctl(j->fd, FIONREAD, &bytes) == -1 || bytes < 0) {
 		job_fail_segment(j, 1, 0);
 		return (-1);
 	}
+	*snapshot = (size_t)bytes;
+	if (*snapshot > JOB_PUMP_PER_JOB)
+		*snapshot = JOB_PUMP_PER_JOB;
+	*snapshot = artifact_capture_read_limit(&j->capture, *snapshot);
 	return (0);
 }
 
+/* Reaping fixes the producer set: the supervisor has killed the process group
+ * and closed every writer.  FIONREAD==0 plus POLLHUP can therefore close EOF
+ * without consuming a byte that belongs to a later poll segment. */
 static void
+job_close_reaped_eof(struct job *j)
+{
+	struct pollfd pfd;
+	int bytes = 0, rc;
+
+	if (!j->exited || j->fd == -1)
+		return;
+	if (ioctl(j->fd, FIONREAD, &bytes) == -1 || bytes < 0) {
+		job_fail_segment(j, 1, 0);
+		return;
+	}
+	if (bytes != 0)
+		return;
+	pfd.fd = j->fd;
+	pfd.events = POLLIN;
+	pfd.revents = 0;
+	rc = poll(&pfd, 1, 0);
+	if (rc == -1) {
+		if (errno != EINTR)
+			job_fail_segment(j, 1, 0);
+		return;
+	}
+	if (rc == 1 && (pfd.revents & POLLHUP)) {
+		close(j->fd);
+		j->fd = -1;
+		if (j->pending_write.len == 0)
+			job_spool_close(j);
+	}
+}
+
+static void
 job_result_terminal(struct job *j, struct tool_ctx *ctx, int *is_error)
 {
 	ctx->artifact_exit_status = 0;
@@ -528,7 +1154,8 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c
     int obj, struct buf *out, int *is_error)
 {
 	struct job	*j;
-	int		 id;
+	size_t		 snapshot = 0;
+	int		 id, terminal = 0;
 
 	if (job_id_arg(js, obj, "shell_output", out, is_error, &id) == -1)
 		return;
@@ -537,15 +1164,39 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c
 		*is_error = 1;
 		return;
 	}
-	(void)job_drain(j, JOB_PUMP_PER_JOB);
+	if (j->artifact_settling) {
+		buf_addf(out, "shell_output: job %d is awaiting Artifact "
+		    "settlement", id);
+		*is_error = 1;
+		return;
+	}
+	ctx->artifact_job_id = j->id;
+	(void)job_poll_snapshot(j, &snapshot);
+	if (job_freeze_snapshot(j, snapshot) == 1)
+		return;
 	job_reap(j);
-	if (j->exited)
-		(void)job_drain(j, JOB_PUMP_PER_JOB);
+	job_close_reaped_eof(j);
 
 	if (ctx->artifact_mode) {
-		int adopt;
+		int adopt, ownership_backlog = 0;
 
-		(void)job_flush_blocking(j);
+		adopt = !j->cap_reached && !j->storage_error &&
+		    !j->storage_unavailable && (!j->exited || j->fd != -1);
+		if (adopt &&
+		    job_ownership_count(jl) >= jl->ownership_limit) {
+			/* Moving current to history is ownership-neutral, but adopting
+			 * this replacement would add another unresolved exact key. */
+			ownership_backlog = 1;
+			adopt = 0;
+			job_stop(j);
+			if (j->fd != -1) {
+				close(j->fd);
+				j->fd = -1;
+			}
+		}
+		if (j->artifact_available && !ownership_backlog &&
+		    job_history_add(j, j->artifact_id) == -1)
+			abort();
 		job_spool_close(j);
 		ctx->artifact_captured = 1;
 		ctx->artifact_observed = j->capture.observed;
@@ -555,25 +1206,45 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c
 			strlcpy(ctx->artifact_result_id, j->artifact_id,
 			    sizeof(ctx->artifact_result_id));
 		job_result_terminal(j, ctx, is_error);
+		if (ownership_backlog) {
+			ctx->artifact_termination = ARTIFACT_TERM_CANCELLED;
+			*is_error = 1;
+		}
 
 		artifact_capture_free(&ctx->artifact_capture);
 		ctx->artifact_capture = j->capture;
 		artifact_capture_init(&j->capture);
-		adopt = !j->cap_reached && !j->storage_error &&
-		    !j->storage_unavailable && (!j->exited || j->fd != -1);
+		terminal = !adopt;
 		j->cap_reached = 0;
 		j->storage_error = 0;
 		j->storage_unavailable = 0;
-		j->artifact_available = 0;
-		j->artifact_managed = 0;
-		memset(j->artifact_id, 0, sizeof(j->artifact_id));
+		if (ownership_backlog && j->artifact_available) {
+			/* The already-published current key is the bounded settlement
+			 * slot.  RELEASE/CANCEL retires it; no replacement is adopted. */
+			j->artifact_settling = 1;
+		} else {
+			j->artifact_available = 0;
+			j->artifact_settling = 0;
+			j->artifact_managed = 0;
+			j->artifact_correlation = 0;
+			memset(j->artifact_id, 0, sizeof(j->artifact_id));
+		}
 		if (adopt) {
 			ctx->artifact_spool_detach = !j->pending;
 			if (job_adopt_stream(j, ctx) == -1) {
 				job_stop(j);
+				if (j->fd != -1) {
+					close(j->fd);
+					j->fd = -1;
+				}
+				j->artifact_available = 0;
+				j->artifact_settling = 0;
+				j->artifact_managed = 0;
+				j->artifact_correlation = 0;
 				ctx->artifact_termination =
 				    ARTIFACT_TERM_STORAGE_ERROR;
 				*is_error = 1;
+				terminal = 1;
 			}
 		}
 	} else {
@@ -590,10 +1261,13 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c
 			    "some was dropped]\n");
 		artifact_capture_reset(&j->capture);
 		j->cap_reached = 0;
+		terminal = j->exited && j->fd == -1;
 	}
-
-	if (ctx->artifact_mode)
+	j->reported_terminal = terminal;
+	if (ctx->artifact_mode) {
+		job_prune_reported(jl);
 		return;
+	}
 	if (j->exited) {
 		if (WIFEXITED(j->status))
 			buf_addf(out, "\n[fugu: job %d exited, status %d]\n",
@@ -603,6 +1277,7 @@ job_output(struct joblist *jl, struct tool_ctx *ctx, c
 			    id, WTERMSIG(j->status));
 	} else
 		buf_addf(out, "\n[fugu: job %d running]\n", id);
+	job_prune_reported(jl);
 }
 
 void
@@ -646,22 +1321,114 @@ job_kill_all(struct joblist *jl)
 	}
 }
 
-void
-job_artifact_cancel(struct joblist *jl, const char *id)
+int
+job_artifact_cancel(struct joblist *jl, int job_id, const char *id)
 {
+	struct job_artifact_tombstone *tomb;
 	struct job *j;
+	int live_match;
 
 	if (!artifact_id_wire_valid(id))
-		return;
-	for (j = jl->head; j != NULL; j = j->next) {
-		if (!j->artifact_available || strcmp(j->artifact_id, id) != 0)
-			continue;
+		return (-1);
+	j = job_find(jl, job_id);
+	live_match = j != NULL && ((j->artifact_available &&
+	    strcmp(j->artifact_id, id) == 0) || job_history_has(j, id));
+	tomb = job_tombstone_find(jl, job_id, id);
+	if (!live_match && (tomb == NULL ||
+	    (tomb->drop_pending && job_artifact_id_zero(tomb->id))))
+		return (-1);
+	if (live_match) {
 		job_fail_segment(j, 1, 0);
-		return;
+		j->artifact_available = 0;
+		j->artifact_settling = 0;
+		j->artifact_managed = 0;
+		j->artifact_correlation = 0;
+		memset(j->artifact_id, 0, sizeof(j->artifact_id));
+		job_history_free(j);
+		job_tombstones_remove_job(jl, job_id);
+		job_prune_reported(jl);
+		return (0);
 	}
+	job_tombstones_remove_job(jl, job_id);
+	return (0);
 }
 
 int
+job_artifact_release(struct joblist *jl, int job_id, const char *id)
+{
+	struct job_artifact_tombstone *tomb;
+	struct job *j;
+	int rc;
+
+	if (!artifact_id_wire_valid(id))
+		return (-1);
+	j = job_find(jl, job_id);
+	if (j != NULL && j->artifact_settling && j->artifact_available &&
+	    strcmp(j->artifact_id, id) == 0) {
+		j->artifact_available = 0;
+		j->artifact_settling = 0;
+		j->artifact_managed = 0;
+		j->artifact_correlation = 0;
+		memset(j->artifact_id, 0, sizeof(j->artifact_id));
+		job_prune_reported(jl);
+		return (0);
+	}
+	if (j != NULL && (rc = job_history_remove(j, id)) == 0) {
+		job_prune_reported(jl);
+		return (0);
+	}
+	tomb = job_tombstone_find(jl, job_id, id);
+	if (tomb == NULL || tomb->drop_pending)
+		return (-1);
+	job_tombstone_remove(jl, tomb);
+	return (0);
+}
+
+int
+job_artifact_drop_next(struct joblist *jl, uint32_t *correlation,
+    struct artifact_spool_drop *drop)
+{
+	struct job_artifact_tombstone *tomb;
+
+	if (correlation == NULL || drop == NULL)
+		return (-1);
+	LIST_FOREACH(tomb, &jl->tombstones, entry) {
+		if (!tomb->drop_pending || tomb->drop_sent)
+			continue;
+		if (tomb->correlation == 0 || tomb->job_id <= 0)
+			return (-1);
+		memset(drop, 0, sizeof(*drop));
+		drop->job_id = (uint32_t)tomb->job_id;
+		strlcpy(drop->artifact_id, tomb->id,
+		    sizeof(drop->artifact_id));
+		*correlation = tomb->correlation;
+		tomb->drop_sent = 1;
+		return (1);
+	}
+	return (0);
+}
+
+int
+job_artifact_drop_ack(struct joblist *jl, uint32_t correlation,
+    const struct artifact_spool_drop *drop)
+{
+	struct job_artifact_tombstone *tomb;
+
+	if (correlation == 0 || drop == NULL || drop->job_id == 0 ||
+	    drop->job_id > INT_MAX || (!artifact_id_wire_valid(drop->artifact_id) &&
+	    !job_artifact_id_zero(drop->artifact_id)))
+		return (-1);
+	tomb = job_tombstone_find(jl, (int)drop->job_id, drop->artifact_id);
+	if (tomb == NULL)
+		return (-1);
+	if (!tomb->drop_pending || !tomb->drop_sent ||
+	    tomb->correlation != correlation)
+		return (-1);
+	job_tombstone_remove(jl, tomb);
+	return (0);
+}
+
+int
 job_turn_begin(struct joblist *jl)
 {
 	if (jl->turn_open)
@@ -680,6 +1447,7 @@ job_turn_commit(struct joblist *jl)
 	for (j = jl->head; j != NULL; j = j->next)
 		j->pending = 0;
 	jl->turn_open = 0;
+	job_prune_reported(jl);
 	return (0);
 }
 
@@ -715,6 +1483,7 @@ job_turn_abort(struct joblist *jl)
 		job_spool_close(j);
 		artifact_capture_free(&j->capture);
 		buf_free(&j->pending_write);
+		job_history_free(j);
 		*link = j->next;
 		free(j);
 	}
@@ -726,6 +1495,7 @@ void
 job_list_free(struct joblist *jl)
 {
 	struct job	*j, *n;
+	struct job_artifact_tombstone *tomb;
 
 	if (jl == NULL)
 		return;
@@ -739,7 +1509,12 @@ job_list_free(struct joblist *jl)
 		job_spool_close(j);
 		artifact_capture_free(&j->capture);
 		buf_free(&j->pending_write);
+		job_history_free(j);
 		free(j);
 	}
+	while ((tomb = LIST_FIRST(&jl->tombstones)) != NULL) {
+		LIST_REMOVE(tomb, entry);
+		free(tomb);
+	}
 	free(jl);
 }
blob - 725cc0a2c5451c676c51809104829f76d81ed03f
blob + 8e3b9e77a89f6c8b6d7171161b8dbd1721937fad
--- src/fugu-tool/job.h
+++ src/fugu-tool/job.h
@@ -17,7 +17,10 @@
 #ifndef JOB_H
 #define JOB_H
 
+#include <stdint.h>
+
 #include "buf.h"
+#include "proto.h"
 
 /*
  * Background shell jobs (behavior.md section 5): shell_bg starts a
@@ -37,6 +40,9 @@ void		 job_list_free(struct joblist *);
 
 /* the max_bg_jobs gate; 0 (the default here) means unconfigured */
 void		 job_set_cap(struct joblist *, int cap);
+/* Regression-only bound selected by fugu-tool under guarded selftest mode. */
+void		 job_selftest_set_ownership_limit(struct joblist *, size_t);
+int		 job_selftest_archive_completed(struct joblist *);
 
 void	 job_start(struct joblist *, struct tool_ctx *, const struct json *,
 	    int obj, struct buf *out, int *is_error);
@@ -47,7 +53,12 @@ void	 job_kill(struct joblist *, const struct json *, 
 
 /* drain every running job's pipe into its ring (called opportunistically) */
 void	 job_pump(struct joblist *);
-void	 job_artifact_cancel(struct joblist *, const char *);
+int	 job_artifact_cancel(struct joblist *, int, const char *);
+int	 job_artifact_release(struct joblist *, int, const char *);
+int	 job_artifact_drop_next(struct joblist *, uint32_t *,
+	    struct artifact_spool_drop *);
+int	 job_artifact_drop_ack(struct joblist *, uint32_t,
+	    const struct artifact_spool_drop *);
 /* kill and reap every job (turn abandon, session end) */
 void	 job_kill_all(struct joblist *);
 
@@ -56,4 +67,37 @@ int	 job_turn_begin(struct joblist *);
 int	 job_turn_commit(struct joblist *);
 int	 job_turn_abort(struct joblist *);
 
+#ifdef JOB_TESTING
+struct job_test_state {
+	uint64_t observed;
+	uint64_t retained;
+	size_t pending_bytes;
+	int cap_reached;
+	int exited;
+	int producer_open;
+	int artifact_open;
+	int storage_error;
+	int storage_unavailable;
+};
+
+void	 job_test_backpressure_at_boundary(void);
+int	 job_test_boundary_pending(struct joblist *, int);
+int	 job_test_storage_unavailable(struct joblist *, int);
+size_t	 job_test_pending_bytes(struct joblist *, int);
+size_t	 job_test_output_bytes(struct joblist *, int);
+void	 job_test_set_pump_limits(size_t, size_t);
+void	 job_test_reset_pump_limits(void);
+size_t	 job_test_pump_per_job_limit(void);
+size_t	 job_test_pump_total_limit(void);
+size_t	 job_test_last_pump_total(struct joblist *);
+size_t	 job_test_last_pump_bytes(struct joblist *, int);
+int	 job_test_state(struct joblist *, int, struct job_test_state *);
+void	 job_test_set_record_limit(struct joblist *, size_t);
+void	 job_test_set_tombstone_limit(struct joblist *, size_t);
+size_t	 job_test_record_count(struct joblist *);
+size_t	 job_test_tombstone_count(struct joblist *);
+void	 job_test_set_ownership_limit(struct joblist *, size_t);
+size_t	 job_test_ownership_count(struct joblist *);
+#endif
+
 #endif /* JOB_H */
blob - 7836e330da2f4510de02c7738bde3617d62e0c1d
blob + 20e8d5ffe78a9765287b58c24e371c50dba899ef
--- src/fugu-tool/main.c
+++ src/fugu-tool/main.c
@@ -39,6 +39,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <syslog.h>
+#include <time.h>
 #include <unistd.h>
 
 #include "log.h"
@@ -50,10 +51,18 @@
 #include "tools.h"
 #include "agent.h"
 #include "artifact_result.h"
+#include "artifact_pipe.h"
+#include "artifact_scope.h"
 
 static int	readonly;	/* -r: the subagent role, always read-only */
+static long long selftest_rotation_delay_ms;
+static long long selftest_drop_delay_ms;
+static int selftest_archive_pending;
+static int selftest_rotation_trace;
+static int selftest_drop_trace;
 
 struct tool_state {
+	struct worker	*worker;
 	struct tool_ctx	*ctx;
 	char		 name[FUGU_TOOL_NAME_MAX];
 	int		 have_name;
@@ -72,7 +81,82 @@ struct tool_state {
  * a clean shutdown that would otherwise orphan them into init. */
 static struct joblist	*jobs_atexit;
 
+static long long
+selftest_delay(const char *name)
+{
+	const char	*value, *errstr;
+	long long	 ms;
+
+	if (issetugid() || getenv("FUGU_SELFTEST") == NULL ||
+	    (value = getenv(name)) == NULL)
+		return (0);
+	ms = strtonum(value, 1, 5000, &errstr);
+	if (errstr != NULL)
+		fatalx("fugu-tool: invalid %s", name);
+	return (ms);
+}
+
+static int
+selftest_flag(const char *name)
+{
+	const char *value;
+
+	if (issetugid() || getenv("FUGU_SELFTEST") == NULL ||
+	    (value = getenv(name)) == NULL)
+		return (0);
+	if (strcmp(value, "1") != 0)
+		fatalx("fugu-tool: invalid %s", name);
+	return (1);
+}
+
 static void
+selftest_pause(long long *milliseconds, const char *marker)
+{
+	struct timespec	 delay;
+	long long	 ms;
+
+	if ((ms = *milliseconds) == 0)
+		return;
+	*milliseconds = 0;
+	log_warnx("%s", marker);
+	delay.tv_sec = (time_t)(ms / 1000);
+	delay.tv_nsec = (long)(ms % 1000) * 1000000L;
+	(void)nanosleep(&delay, NULL);
+}
+
+static int
+spool_drop_id_valid(const char id[FUGU_ARTIFACT_ID_BUFSZ])
+{
+	static const char zero[FUGU_ARTIFACT_ID_BUFSZ];
+
+	return (artifact_id_wire_valid(id) ||
+	    memcmp(id, zero, sizeof(zero)) == 0);
+}
+
+static void
+send_spool_drops(struct worker *w, struct tool_state *ts)
+{
+	struct artifact_spool_drop drop;
+	uint32_t correlation;
+	int rc;
+
+	for (;;) {
+		correlation = 0;
+		memset(&drop, 0, sizeof(drop));
+		rc = job_artifact_drop_next(ts->ctx->jobs, &correlation, &drop);
+		if (rc == 0)
+			return;
+		if (rc == -1 || correlation == 0 || drop.job_id == 0 ||
+		    !spool_drop_id_valid(drop.artifact_id))
+			fatalx("fugu-tool: invalid Artifact spool drop");
+		imsgev_send(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_DROP, correlation,
+		    &drop, sizeof(drop));
+		selftest_pause(&selftest_drop_delay_ms,
+		    "fugu-tool: selftest Artifact DROP queued");
+	}
+}
+
+static void
 terminate_signal(int sig)
 {
 	(void)sig;
@@ -100,8 +184,13 @@ pump_jobs(int fd, short ev, void *arg)
 
 	(void)fd;
 	(void)ev;
-	if (ts->ctx != NULL)
+	if (ts->ctx != NULL) {
 		job_pump(ts->ctx->jobs);
+		if (selftest_archive_pending &&
+		    job_selftest_archive_completed(ts->ctx->jobs) == 1)
+			selftest_archive_pending = 0;
+		send_spool_drops(ts->worker, ts);
+	}
 }
 
 /*
@@ -340,7 +429,10 @@ tool_lockdown(struct worker *w, struct imsg *imsg)
 	}
 
 	ts->ctx = tool_ctx_new(write_ok, net_ok);
+	ts->worker = w;
 	job_set_cap(ts->ctx->jobs, (int)ts->cap);
+	if (selftest_rotation_delay_ms > 0)
+		job_selftest_set_ownership_limit(ts->ctx->jobs, 1);
 
 	/* kill our background jobs when we exit, and keep their output
 	 * draining between polls (event_init ran before this dispatch) */
@@ -377,6 +469,43 @@ send_result(struct worker *w, uint32_t id, const struc
 	    sizeof(*result));
 }
 
+/* Publish every completed shell_output freeze independently of the ordinary
+ * result.  replacement_adopted distinguishes a swap from a terminal/error
+ * freeze; no frame means the Tool call did not mutate spool ownership.  The
+ * Coordinator may abandon the ordinary result after SIGUSR1, but this frame
+ * still proves exactly which old ownership was frozen. */
+static void
+send_spool_rotation(struct worker *w, const struct tool_state *ts, uint32_t id)
+{
+	struct artifact_spool_rotate rotate;
+
+	if (!ts->ctx->artifact_captured || ts->ctx->artifact_job_id <= 0)
+		return;
+	if (id == 0)
+		fatalx("fugu-tool: invalid Artifact spool rotation");
+	if (!ts->ctx->artifact_spool_adopted && ts->ctx->artifact_spool_detach)
+		fatalx("fugu-tool: detached unadopted Artifact spool");
+	memset(&rotate, 0, sizeof(rotate));
+	rotate.job_id = (uint32_t)ts->ctx->artifact_job_id;
+	rotate.replacement_adopted = ts->ctx->artifact_spool_adopted;
+	rotate.detach = ts->ctx->artifact_spool_detach;
+	if (ts->ctx->artifact_result_available)
+		strlcpy(rotate.frozen_id, ts->ctx->artifact_result_id,
+		    sizeof(rotate.frozen_id));
+	if (ts->ctx->artifact_spool_adopted && ts->ctx->artifact_available)
+		strlcpy(rotate.replacement_id, ts->artifact.id,
+		    sizeof(rotate.replacement_id));
+	if (selftest_rotation_delay_ms > 0) {
+		imsgev_send_blocking(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_ROTATE, id,
+		    &rotate, sizeof(rotate));
+		selftest_pause(&selftest_rotation_delay_ms,
+		    "fugu-tool: selftest frame0 Artifact rotation published");
+		return;
+	}
+	imsgev_send(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_ROTATE, id, &rotate,
+	    sizeof(rotate));
+}
+
 /* Replace raw in-scope bytes with the single generated provider document.
  * artifact_result emits id first exactly when the staging object is needed. */
 static void
@@ -396,6 +525,7 @@ serialize_artifact_result(struct tool_state *ts, struc
 		result->is_error = *is_error;
 		result->spool_adopted = 1;
 		result->spool_detach = ts->ctx->artifact_spool_detach;
+		result->background_job_id = ts->ctx->artifact_job_id;
 		if (ts->ctx->artifact_available)
 			strlcpy(result->artifact_id, ts->artifact.id,
 			    sizeof(result->artifact_id));
@@ -416,7 +546,10 @@ serialize_artifact_result(struct tool_state *ts, struc
 	memset(&in, 0, sizeof(in));
 	in.raw = NULL;
 	in.raw_len = ts->ctx->artifact_raw_len;
-	in.id = ts->ctx->artifact_captured ?
+	/* A foreground capture writes the stream brokered for this call.  A
+	 * shell_output capture instead describes the frozen prior segment and
+	 * therefore uses only that job's recorded result id. */
+	in.id = ts->ctx->artifact_captured && ts->ctx->artifact_job_id > 0 ?
 	    (ts->ctx->artifact_result_available ?
 	    ts->ctx->artifact_result_id : NULL) :
 	    (ts->ctx->artifact_available ? ts->artifact.id : NULL);
@@ -441,8 +574,11 @@ serialize_artifact_result(struct tool_state *ts, struc
 	result->artifact = 1;
 	result->keep_artifact = keep;
 	result->artifact_cap_reached = ts->ctx->artifact_cap_reached;
+	result->spool_frozen = ts->ctx->artifact_captured &&
+	    ts->ctx->artifact_job_id > 0;
 	result->spool_adopted = ts->ctx->artifact_spool_adopted;
 	result->spool_detach = ts->ctx->artifact_spool_detach;
+	result->background_job_id = ts->ctx->artifact_job_id;
 	result->termination = ts->ctx->artifact_termination;
 	result->observed_bytes = ts->ctx->artifact_observed;
 	result->retained_bytes = ts->ctx->artifact_raw_len;
@@ -464,10 +600,10 @@ tool_dispatch(struct worker *w, struct imsg *imsg)
 	switch (imsg_get_type(imsg)) {
 	case FUGU_IMSG_TOOL_ARTIFACT_STREAM: {
 		struct artifact_stream stream;
-		struct stat st;
-		int fd = -1, flags, fdflags;
+		int fd = -1;
 
-		if (readonly || ts->agent || ts->have_artifact ||
+		if (readonly || ts->agent || ts->have_artifact || ts->have_name ||
+		    ts->arg.len != 0 ||
 		    imsg_get_id(imsg) == 0)
 			fatalx("fugu-tool: unexpected Artifact stream");
 		if (imsgev_get_fd_payload(imsg, &fd, &payload) == -1) {
@@ -484,11 +620,7 @@ tool_dispatch(struct worker *w, struct imsg *imsg)
 		memcpy(&stream, p, sizeof(stream));
 		if ((stream.status == ARTIFACT_STREAM_AVAILABLE &&
 		    (fd == -1 || !artifact_id_wire_valid(stream.id) ||
-			    fstat(fd, &st) == -1 || !S_ISFIFO(st.st_mode) ||
-			    (flags = fcntl(fd, F_GETFL)) == -1 ||
-			    flags != O_WRONLY ||
-			    (fdflags = fcntl(fd, F_GETFD)) == -1 ||
-			    (fdflags & FD_CLOEXEC) == 0)) ||
+			    !artifact_producer_endpoint_valid(fd))) ||
 			    (stream.status == ARTIFACT_STREAM_UNAVAILABLE &&
 			    (fd != -1 || memcmp(stream.id,
 			    (char[FUGU_ARTIFACT_ID_BUFSZ]){0}, sizeof(stream.id)) != 0)) ||
@@ -558,32 +690,72 @@ tool_dispatch(struct worker *w, struct imsg *imsg)
 		    sizeof(ack));
 		return (0);
 	}
-	case FUGU_IMSG_TOOL_ARTIFACT_CANCEL: {
+	case FUGU_IMSG_TOOL_ARTIFACT_DROP_ACK: {
+		struct artifact_spool_drop drop;
+		uint32_t correlation = imsg_get_id(imsg);
+
+		if (readonly || ts->agent || correlation == 0 ||
+		    imsgev_get_payload(imsg, &payload) == -1 ||
+		    payload.data == NULL || payload.len != sizeof(drop))
+			fatalx("fugu-tool: bad Artifact drop acknowledgement");
+		memcpy(&drop, payload.data, sizeof(drop));
+		if (job_artifact_drop_ack(ts->ctx->jobs, correlation, &drop) == -1)
+			fatalx("fugu-tool: stale Artifact drop acknowledgement");
+		return (0);
+	}
+	case FUGU_IMSG_TOOL_ARTIFACT_CANCEL:
+	case FUGU_IMSG_TOOL_ARTIFACT_RELEASE: {
+		struct artifact_job_cancel_ack ack;
 		struct artifact_record record;
+		uint32_t type = imsg_get_type(imsg), job_id = imsg_get_id(imsg);
+		int rc;
 
-		if (readonly || ts->agent || imsg_get_id(imsg) != 0 ||
+		if (readonly || ts->agent || job_id == 0 || job_id > INT_MAX ||
 		    imsgev_get_payload(imsg, &payload) == -1 ||
 		    payload.data == NULL || payload.len != sizeof(record))
 			fatalx("fugu-tool: bad Artifact cancellation");
 		memcpy(&record, payload.data, sizeof(record));
 		if (!artifact_id_wire_valid(record.id))
 			fatalx("fugu-tool: malformed Artifact cancellation id");
-		job_artifact_cancel(ts->ctx->jobs, record.id);
+		rc = type == FUGU_IMSG_TOOL_ARTIFACT_CANCEL ?
+		    job_artifact_cancel(ts->ctx->jobs, (int)job_id, record.id) :
+		    job_artifact_release(ts->ctx->jobs, (int)job_id, record.id);
+		if (rc == -1)
+			fatalx("fugu-tool: stale Artifact job ownership");
+		if (type == FUGU_IMSG_TOOL_ARTIFACT_CANCEL) {
+			memset(&ack, 0, sizeof(ack));
+			ack.job_id = job_id;
+			strlcpy(ack.artifact_id, record.id,
+			    sizeof(ack.artifact_id));
+			imsgev_send(&w->iev, FUGU_IMSG_TOOL_ARTIFACT_CANCEL_ACK,
+			    job_id, &ack, sizeof(ack));
+			if (selftest_drop_trace)
+				log_warnx("fugu-tool: selftest Artifact CANCEL_ACK "
+				    "job=%u", job_id);
+		} else if (selftest_rotation_trace) {
+			log_warnx("fugu-tool: selftest Artifact RELEASE job=%u",
+			    job_id);
+		}
 		return (0);
 	}
 	case FUGU_IMSG_TOOL_REQ: {
 		struct tool_req	 req;
+		int		 scoped;
 
 		if (imsgev_get_payload(imsg, &payload) == -1)
 			fatal("get TOOL_REQ payload");
 		p = payload.data;
 		len = payload.len;
-		if (p == NULL || len != sizeof(req) ||
-		    (ts->have_artifact && imsg_get_id(imsg) != ts->artifact_id) ||
-		    (!ts->have_artifact && imsg_get_id(imsg) != 0))
+		if (p == NULL || len != sizeof(req) || ts->have_name ||
+		    ts->arg.len != 0)
 			fatalx("fugu-tool: short TOOL_REQ");
 		memcpy(&req, p, sizeof(req));
 		req.name[sizeof(req.name) - 1] = '\0';
+		scoped = artifact_capture_tool_name(req.name);
+		if ((!readonly && !ts->agent && !!ts->have_artifact != !!scoped) ||
+		    (ts->have_artifact && imsg_get_id(imsg) != ts->artifact_id) ||
+		    (!ts->have_artifact && imsg_get_id(imsg) != 0))
+			fatalx("fugu-tool: Artifact scope mismatch");
 		strlcpy(ts->name, req.name, sizeof(ts->name));
 		ts->have_name = 1;
 		buf_reset(&ts->arg);
@@ -664,6 +836,8 @@ tool_dispatch(struct worker *w, struct imsg *imsg)
 		ts->ctx->artifact_available = ts->have_artifact &&
 		    ts->artifact.status == ARTIFACT_STREAM_AVAILABLE;
 		ts->ctx->artifact_id = ts->have_artifact ? ts->artifact.id : NULL;
+		ts->ctx->artifact_correlation = ts->have_artifact ?
+		    ts->artifact_id : 0;
 		ts->ctx->artifact_observed = 0;
 		ts->ctx->artifact_raw_len = 0;
 		ts->ctx->artifact_termination = ARTIFACT_TERM_EXIT;
@@ -673,9 +847,11 @@ tool_dispatch(struct worker *w, struct imsg *imsg)
 		ts->ctx->artifact_captured = 0;
 		tool_exec(ts->ctx, ts->name, ts->arg.data, ts->arg.len,
 		    &out, &is_error);
-		if (ts->have_artifact)
+		send_spool_drops(w, ts);
+		if (ts->have_artifact) {
+			send_spool_rotation(w, ts, ts->artifact_id);
 			serialize_artifact_result(ts, &out, &is_error, &result);
-		else
+		} else
 			result.is_error = is_error;
 		ts->artifact_fd = ts->ctx->artifact_fd;
 		if (ts->artifact_fd != -1) {
@@ -702,6 +878,7 @@ tool_dispatch(struct worker *w, struct imsg *imsg)
 		ts->ctx->artifact_mode = 0;
 		ts->ctx->artifact_available = 0;
 		ts->ctx->artifact_id = NULL;
+		ts->ctx->artifact_correlation = 0;
 		return (0);
 	}
 	default:
@@ -723,10 +900,19 @@ main(int argc, char *argv[])
 	sigemptyset(&sa.sa_mask);
 	if (sigaction(SIGTERM, &sa, NULL) == -1)
 		fatal("sigaction SIGTERM");
+	if (sigaction(SIGUSR1, &sa, NULL) == -1)
+		fatal("sigaction SIGUSR1");
 
 	buf_init(&ts.arg);
 	ts.artifact_fd = -1;
 	agent_boot_init(&ts.boot);
+	selftest_rotation_delay_ms = selftest_delay(
+	    "FUGU_ARTIFACT_FRAME0_DELAY_MS");
+	selftest_drop_delay_ms = selftest_delay("FUGU_ARTIFACT_DROP_DELAY_MS");
+	selftest_archive_pending = selftest_flag(
+	    "FUGU_ARTIFACT_ARCHIVE_COMPLETED");
+	selftest_rotation_trace = selftest_rotation_delay_ms > 0;
+	selftest_drop_trace = selftest_drop_delay_ms > 0;
 
 	/*
 	 * Transitional pledge.  It is a superset of the steady promises;
blob - b1cfd11bd33072fd502be44807cdb2d017a74aaf
blob + 8091e3a43e76e43139bbe49a9f1cfb6292913d93
--- src/fugu-tool/tools.c
+++ src/fugu-tool/tools.c
@@ -781,23 +781,50 @@ deadline_ms(const struct timespec *deadline)
 #define CAP_ARTIFACT_CAP (-5)
 
 static volatile sig_atomic_t active_pgid;
+static volatile sig_atomic_t active_cancelled;
 
-static int
-artifact_write_all(int fd, const void *data, size_t len)
+enum artifact_write_result {
+	ARTIFACT_WRITE_OK = 0,
+	ARTIFACT_WRITE_ERROR,
+	ARTIFACT_WRITE_TIMEOUT,
+	ARTIFACT_WRITE_CANCELLED
+};
+
+static enum artifact_write_result
+artifact_write_all(int fd, const void *data, size_t len,
+    const struct timespec *deadline)
 {
 	const u_char *p = data;
+	struct pollfd pfd;
 	ssize_t n;
 
 	while (len > 0) {
-		do {
-			n = write(fd, p, len);
-		} while (n == -1 && errno == EINTR);
-		if (n <= 0)
-			return (-1);
-		p += n;
-		len -= (size_t)n;
+		if (active_cancelled)
+			return (ARTIFACT_WRITE_CANCELLED);
+		n = write(fd, p, len);
+		if (n > 0) {
+			p += n;
+			len -= (size_t)n;
+			continue;
+		}
+		if (n == -1 && errno == EINTR)
+			continue;
+		if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
+			int left = deadline_ms(deadline);
+
+			if (left == 0)
+				return (ARTIFACT_WRITE_TIMEOUT);
+			pfd.fd = fd;
+			pfd.events = POLLOUT;
+			if (left > 100)
+				left = 100;
+			if (poll(&pfd, 1, left) == -1 && errno != EINTR)
+				return (ARTIFACT_WRITE_ERROR);
+			continue;
+		}
+		return (ARTIFACT_WRITE_ERROR);
 	}
-	return (0);
+	return (ARTIFACT_WRITE_OK);
 }
 
 void
@@ -805,10 +832,17 @@ tool_cancel_active(void)
 {
 	pid_t	 pid = (pid_t)active_pgid;
 
+	active_cancelled = 1;
 	if (pid > 0)
 		(void)kill(-pid, SIGKILL);
 }
 
+int
+tool_cancel_requested(void)
+{
+	return (active_cancelled != 0);
+}
+
 /* Reap and clear the published process group with SIGTERM blocked, so a
  * teardown signal can never target a pid after its group leader was reaped
  * and made available for reuse. */
@@ -821,6 +855,7 @@ wait_active_child(struct supervised_proc *sp, int *sta
 
 	sigemptyset(&block);
 	sigaddset(&block, SIGTERM);
+	sigaddset(&block, SIGUSR1);
 	if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1)
 		return (-1);
 	r = waitpid(sp->pid, &supervisor_status, 0);
@@ -854,6 +889,7 @@ reap_active_group(struct supervised_proc *sp, int *sta
 
 	sigemptyset(&block);
 	sigaddset(&block, SIGTERM);
+	sigaddset(&block, SIGUSR1);
 	if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1)
 		return (-1);
 	memset(&si, 0, sizeof(si));
@@ -908,12 +944,14 @@ run_capture(struct tool_ctx *ctx, char *const argv[], 
 	pid_t		 pid, pgid;
 	int		 pipefd[2], status = 0, reaped = 0;
 	int		 timed_out = 0, truncated = 0, storage_error = 0;
+	int		 flags;
 	size_t		 capture_cap = ctx->artifact_mode ? ARTIFACT_RAW_MAX :
 			    CAPTURE_CAP;
 	int		 tmo = ctx->shell_timeout_ms > 0 ?
 			    ctx->shell_timeout_ms : SHELL_TIMEOUT;
 
 	*sig = 0;
+	active_cancelled = 0;
 	if (pipe(pipefd) == -1) {
 		result_err(out, is_error, "pipe: %s", strerror(errno));
 		return (CAP_SPAWNERR);
@@ -926,8 +964,17 @@ run_capture(struct tool_ctx *ctx, char *const argv[], 
 		close(pipefd[1]);
 		return (CAP_SPAWNERR);
 	}
+	if (ctx->artifact_mode && ctx->artifact_available &&
+	    ((flags = fcntl(ctx->artifact_fd, F_GETFL)) == -1 ||
+	    fcntl(ctx->artifact_fd, F_SETFL, flags | O_NONBLOCK) == -1)) {
+		result_err(out, is_error, "artifact fcntl: %s", strerror(errno));
+		close(pipefd[0]);
+		close(pipefd[1]);
+		return (CAP_STORAGE);
+	}
 	sigemptyset(&block);
 	sigaddset(&block, SIGTERM);
+	sigaddset(&block, SIGUSR1);
 	if (sigprocmask(SIG_BLOCK, &block, &oldmask) == -1) {
 		result_err(out, is_error, "sigprocmask: %s", strerror(errno));
 		close(pipefd[0]);
@@ -959,6 +1006,7 @@ run_capture(struct tool_ctx *ctx, char *const argv[], 
 	for (;;) {
 		u_char	 chunk[16384];
 		ssize_t	 n;
+		size_t	 want = sizeof(chunk);
 		int	 left = deadline_ms(&deadline), w;
 
 		w = 0;
@@ -991,7 +1039,15 @@ run_capture(struct tool_ctx *ctx, char *const argv[], 
 				goto capture_done;
 			continue;
 		}
-		n = read(pipefd[0], chunk, sizeof(chunk));
+		if (ctx->artifact_mode) {
+			want = artifact_capture_read_limit(&ctx->artifact_capture,
+			    want);
+			if (want == 0) {
+				truncated = 1;
+				break;
+			}
+		}
+		n = read(pipefd[0], chunk, want);
 		if (n == -1) {
 			if (errno == EAGAIN || errno == EINTR)
 				continue;
@@ -1014,9 +1070,13 @@ run_capture(struct tool_ctx *ctx, char *const argv[], 
 			    (size_t)ctx->artifact_capture.retained;
 			if (ctx->artifact_available &&
 			    ctx->artifact_capture.retained > before &&
-			    artifact_write_all(ctx->artifact_fd, chunk,
-			    (size_t)ctx->artifact_capture.retained - before) == -1) {
-				storage_error = 1;
+			    (w = artifact_write_all(ctx->artifact_fd, chunk,
+			    (size_t)ctx->artifact_capture.retained - before,
+			    &deadline)) != ARTIFACT_WRITE_OK) {
+				if (w == ARTIFACT_WRITE_TIMEOUT)
+					timed_out = 1;
+				else if (w == ARTIFACT_WRITE_ERROR)
+					storage_error = 1;
 				break;
 			}
 			if (capture_rc == 1) {
@@ -1351,12 +1411,14 @@ tool_exec(struct tool_ctx *ctx, const char *name, cons
 	int		 obj;
 
 	*is_error = 0;
+	active_cancelled = 0;
 	buf_reset(out);
 	if (ctx->artifact_mode) {
 		artifact_capture_reset(&ctx->artifact_capture);
 		ctx->artifact_spool_adopted = 0;
 		ctx->artifact_spool_detach = 0;
 		ctx->artifact_result_available = 0;
+		ctx->artifact_job_id = 0;
 		memset(ctx->artifact_result_id, 0,
 		    sizeof(ctx->artifact_result_id));
 		ctx->artifact_observed = 0;
blob - 6c12b8371e00e5da0223343f26eeab559cd28c55
blob + 34d0433d53ebf94228f96040f1f521cf5a49d667
--- src/fugu-tool/tools.h
+++ src/fugu-tool/tools.h
@@ -47,6 +47,7 @@ struct tool_ctx {
 	int		 artifact_available;
 	int		 artifact_fd;
 	const char	*artifact_id;
+	uint32_t	 artifact_correlation;
 	uint64_t	 artifact_observed;
 	size_t		 artifact_raw_len;
 	enum artifact_termination artifact_termination;
@@ -59,6 +60,7 @@ struct tool_ctx {
 	int		 artifact_spool_detach;
 	int		 artifact_result_available;
 	char		 artifact_result_id[ARTIFACT_ID_HEX_LEN + 1];
+	int		 artifact_job_id;
 };
 
 struct tool_ctx	*tool_ctx_new(int allow_write, int allow_subprocess_net);
@@ -79,6 +81,7 @@ int	 tool_known(const char *name);
 
 /* SIGTERM teardown hook: kill a synchronous subprocess group, if any. */
 void	 tool_cancel_active(void);
+int	 tool_cancel_requested(void);
 
 /*
  * Project context (behavior.md 4), read and written in the executor