# Stream AW4: the live collectors page, rebuilt as a control room

**Status 2026-09-19 (design decided by the AW4.1 panel; no code, no run):** three designs, two judges;
the split (robust on correctness, simplest on buildability) went to the correctness judge: robust,
with its drop list applied up front and the grafts both judges named. Away run (`AWAY-2026-09-19.md`
section 3), branch `claude/collectors-live-page-plan-8951e2`, base `05941b4`; D3 follows, then W2.
Goal: `/collectors#live` runs collection (start, pause, resume, stop, pace, mode, progress, memory, the
hours group; rian's list of 19 Sep) and replaces `docker exec`, not the collectors.

## Design (decided)

Fixed by the spec and `agents.md`: control on `sources`, the hook in the fetch port, a heartbeat per
request, the pace floored at the host's robots value, a GET never writes, every write audits the
actor, owner and `sources.manage`, `SOURCE_REFUSED` final, one word per concept (`off` the kill
switch, `paused` the cooperative state; no em dash, never "cheap" or "free"), `main/check.sh` before
every commit; never deploy, run a collection or write staging's database. `datetime.now(UTC)`
everywhere (one host). Out of scope: shedding as a route; `sources.notes`; `verify_source`. Dropped up
front: `starting`, `spawned_*`, a settle reserve, `paused_seconds`, `loop_state`. Assumed (rian's to
overturn): a stop commits the batch; hours stay `app.cli hours collect`; shedding stays in `guard.sh`.

### D1. Migration `aw4a1b2c3d4e_collector_control.py`

`down_revision` = the one head `alembic heads` prints at write time (`aw2c1d2e3f4a` at 42021d2); one
head after. Schema-only, server defaults, no CHECKs, no backfill; `status` gains the value `stopped`.

| table | columns (type, default): written by |
|---|---|
| `collection_runs` | `heartbeat_at` (timestamptz): the hook, every boundary and poll · `requests_made` (int 0): the hook, per boundary · `pid` (int): the child at run creation · `mode` (varchar(12) `'discover'`) · `limit_n` (int, NULL = unlimited; `limit` is reserved in SQL) · `expected_total` (int, NULL = unknown) · `existing_checked`, `existing_changed`, `existing_missing`, `new_found`, `new_brands`, `new_lines`, `new_variants` (int 0): the loop · `started_by` (varchar(80): `--by` or `shell`) · `stopped_by` (varchar(80): `control_set_by`, `kill-switch`, `signal`, `rules`) |
| `sources` | `control` (varchar(8) `'run'`; `run \| pause \| stop`): the API only, the loop never writes it · `control_set_by`, `control_set_at` · `delay_set_by`, `delay_set_at`: the `pace` route · `robots_crawl_delay` (numeric(6,2)), `robots_read_at`: the loop via `note_robots`, a published fact not a control · `mode` (varchar(12) `'discover'`): the default the next Start uses |

### D2. Constants, one home each, with the reason

`control.py`: `OUR_FLOOR = 1.0` s (below one second is a request storm); `PACE_CAP = 600` s (slower
than any crawl delay met); `CONTROL_POLL = 2` s (control re-read every poll while sleeping or paused:
pause, stop and a pace change land in 2 s plus the request in flight); `STOP_GRACE = 60` s (the
longest request is a 40 s fetch or a 90 s render). `ingest.py`: `STUCK_HEARTBEAT = 1 h` (six caps; a
slow host never makes a live run dead). `collector_view.py`: `STALL_FLOOR = 10 min`, `STALL_DELAYS = 5`
(stalled at `max(STALL_FLOOR, 5 x delay)`, a page word never a row state); `ETA_MIN_DONE = 3`;
`PERCENT_CAP_RUNNING = 99`; `EVENTS_PER_COLLECTOR = 5`; `LIVE_MAX_STATEMENTS = 12`. `procinfo.py`:
`START_HEADROOM_BYTES = 3.0 GiB` (400 MiB under the shed line); `SHED_AT_BYTES = 3400 MiB` (`guard.sh`'s
`SHED_MIB`); `FREEZE_STALE = 6 h`. `config.py`: `collect_freeze_file = uploads_dir / ".collect-freeze"`.
`web/src/lib/`: `POLL_VISIBLE_MS 3000`, `POLL_HIDDEN_MS 30000`, `STALE_AFTER_MS 15000`, `COUNT_UP_MS 600`.

### D3. The hook: `collectors/control.py` (the skip-sink pattern, one ContextVar)

`class RunStopped(BaseException)` (`reason`, `by`; like `KeyboardInterrupt`, so `dubai.py`'s `except
Exception` cannot swallow it). `RunControl(run_id, source_id, slug, db, sleeper, clock, stop_flag,
robots_pending)`, `db` the INGEST session, never a second one. Unbound: sleeps as today. Bound:
1. `db.commit()` the open batch (a failure re-raises: `error` as today); assert no nested transaction.
2. `_poll(boundary=True)`: `UPDATE collection_runs SET heartbeat_at, requests_made+1 WHERE id=:run AND
   status='running'`, rowcount 0 → `RunStopped("superseded","rules")`; with `robots_pending`, `UPDATE
   sources SET robots_crawl_delay, robots_read_at` too; a column SELECT (never the identity-mapped
   `Source`, stale under `expire_on_commit=False`) of `control, control_set_by, control_set_at,
   delay_seconds, enabled`; `db.commit()`.
3. Decide: `enabled` false → `RunStopped("kill switch","kill-switch")`; `stop_flag` →
   `RunStopped("signal","signal")`; effective `stop` → `RunStopped("stopped from the page", set_by)`;
   `pause` → 4; else 5.
4. Pause loop: `sleeper(CONTROL_POLL)`, `_poll(False)` (heartbeat + SELECT + commit, no
   `requests_made`), re-decide. A pause heartbeats every 2 s and is never silent.
5. The wait: `wait = 0 if requested_delay == 0 else max(requested, robots delay read THIS run, live
   delay)`, `deadline = t0 + wait` (zero: the deliberate first fetch; control still read once). Loop:
   `remaining = deadline - clock()`, `<= 0` → return; `sleeper(min(remaining, CONTROL_POLL))`;
   `_poll(False)`; re-decide as 3 and 4 (a pause holds the deadline); `deadline = t0 + max(..., new)`.

`effective_control(control, set_at, run_started_at)` = `control` when `set_at >= run_started_at`, else
`run`: a stop pressed yesterday never kills tonight's shell or admit-queue run; nothing resets the field.
Call sites: `fetch()` where it slept (at 0 too); `render()` with `max(0, remaining)` after `render_wait`
(slower, never faster); `check_allowed()` calls `note_robots`.

### D4. `run_collector`: the child's lock, the signal, the end

Signature gains `mode="discover"`, `by=None`. Before the run row, **the child's advisory lock** on a
second, dedicated connection used for nothing else: `pg_try_advisory_lock(hashtext('dfp.collect.' ||
:slug))` (SQLite: no-op true); `false` → no run row, log `collector_locked`, exit 3. It never touches
`collection_runs` or `sources`; released in `finally` or when the process dies. This, not the API
check, closes two Starts at once (a double click, two tabs, the page racing the admit-queue). The run
row is created with `pid`, `mode`, `limit_n`, `started_by`, `expected_total`, committed at once.
The SIGTERM handler (main thread only, restored in `finally`) sets `stop_flag` and never raises; the
raise happens at the next poll (2 s while sleeping, else after the request in flight): never lost in
a broad `except`, never mid-flush; `guard.sh`'s shed now ends a run `stopped`. `except RunStopped`:
commit the open batch, then `status="stopped"`, `stopped_by`, `finished_at`, commit; if that commit
fails, roll back and write the `stopped` row alone, `error="batch lost at stop"`. `finally` (new):
unbind, restore the handler, close the lock connection, stamp `finished_at` if unset, commit.
`cmd_collect` gains `--mode`, `--by` (default `shell`), `--ignore-freeze` (else the marker is exit 3);
`cmd_status` prints `state_of`'s word, the kill switch as `off`.

### D5. Modes, counters, progress (the loop body stays where its tests read it)

**The generator seam:** `for raw in collector.collect(...)` becomes `for raw in _listings(...)`:
`discover` yields from `collect()`; `recheck` walks `held_listing_refs(db, shops)[:limit]` through
`read_one(ref)` under the same hook: `None` → `existing_missing += 1`; `FetchError` not gone →
`count_skip(run, "recheck_fetch_error")`; `SourceBlocked` and `RobotsUnavailable` propagate; no listing
state written. **Counters, after resolution in the loop body, no ORM events** (a savepoint rolled
back on the barcode race never counts). Loaded at start: `held_at_start: set[int]`, `last_price:
dict[id, (price, currency) | None]` for the run's shops, `known_brand_ids`, `known_line_ids`. After
`_resolve_listing`: id in `held_at_start` → `existing_checked += 1`, plus `existing_changed += 1` when
`last_price[id] != (raw.price, raw.currency)`; else `new_found += 1` (`seen_ids` guards a second
sighting). `new_variants += 1` when `product._arrived` reads true (before the loop clears it);
`new_brands` / `new_lines` when the id is not in the known set, then added. `existing_missing` in
discover = `len(held_at_start - seen_ids)` at the end, **only when unlimited and ended `ok`**; else 0,
"not measured". **`expected_total`:** recheck `len(refs)` (exact; empty refused at start); discover
`products_seen` of the newest run with `status='ok' AND limit_n IS NULL` (W2's `--limit 30` run must
never be the denominator), `min(limit, that)` when limited; none → NULL. `done`: discover
`products_seen`; recheck `existing_checked + existing_missing`. Pure: `percent_done(done, expected, *,
finished)` (None below `ETA_MIN_DONE` or without expected; 100 only finished; else `min(99, ...)` with
`over_expected`); `eta_seconds` = `elapsed * (expected - done) / done`, None on the same conditions.

### D6. Liveness and the state words

`is_stuck(run, now, boot)` keeps its signature and gains one clause between the timer and the boot
rule: `heartbeat_at` older than `STUCK_HEARTBEAT` is dead; NULL falls through. It never reads `/proc`
(the host dev server has the wrong pid namespace); `backfill stuck_runs` gains the rule through it,
`refuse_if_collecting` does not (D11). `procinfo.py`, pure over a root path: `container_memory(cgroup)
-> (used, limit | None, readable)`, `rss_of(pid, proc)`, `alive(pid, needle, proc) -> bool | None` (None
unreadable; false for pid <= 1, a zombie, or a cmdline without `app.cli`, `collect`, `--source <slug>`).
`state_of(source, run, last, refused_since, now, alive) -> (state, state_detail)`, pure; a GET marks nothing:

| state | rule | detail; actions |
|---|---|---|
| `dead` | running row and `is_stuck` | "dead: no heartbeat for 1 h 12" / "began before this container"; **Mark as ended** (marks it stuck with the actor), never Stop |
| `stalled` | running row; `alive is False`, or heartbeat older than `max(STALL_FLOOR, STALL_DELAYS x delay)` | "no heartbeat for 14 min (pace 60 s, 5 min expected)" / "process gone"; Stop, Stop now (SIGTERM) |
| `stopping` | running row; `enabled` false or effective control `stop` | "stopping: waits for the request in flight"; Stop now after `STOP_GRACE` since `control_set_at` |
| `paused` | running row; effective `pause` and `heartbeat_at >= control_set_at` | "paused 2 min by rian · holds approvals while paused"; Resume, Stop |
| `pausing` | running row; effective `pause`, not yet acknowledged | "pausing: next request boundary in about N s"; Resume |
| `running` | a running row | the bar; Pause, Stop |
| `off` | no running row; `enabled` false | "switched off (the kill switch)"; nothing |
| `refused` | no running row; last run `blocked`, or `refused_since` newer than the last run | "refused since 11 Sep: <error>"; Start disabled with `start_blocked.summary` |
| `idle` | otherwise | "last: ok 19 Sep 03:12, 412 prices in 2 h 10"; Start with the mode menu |

### D7. The six routes (`routers/collectors.py`), all `PERM_SOURCES_MANAGE`

Every route: `identity.actor(request)` first; pydantic body; the source row `FOR NO KEY UPDATE` in one
short transaction (a running collector's FK holds only KEY SHARE); `audit_log.record("source.<verb>",
entity_type="source", entity_key=slug, detail=...)`; the fresh `LiveCollector` returned. A refusal is
409 `{error_code, summary}` (`_control_refused`); 404 `SOURCE_NOT_FOUND`; 422 for a bad body.

| route | body | refusals, in order | writes |
|---|---|---|---|
| `start` | `{mode?, limit?: 1..100000}` | `SOURCE_DISABLED`, `SOURCE_REFUSED` (the legal reasons first; final; the summary names the host and date and says the lift is rian's recorded decision), `SOURCE_RUNNING` (a running row `is_stuck` does not call dead), `MODE_UNSUPPORTED` (recheck on a collector whose module imports `render`: `RENDER_DEFAULT_CAP` ends the walk at 25), `NOTHING_TO_RECHECK` (no held listing with a URL), `COLLECT_FROZEN` (summary = the marker's stamp line and its age), `MEMORY_LOW` (`used > START_HEADROOM_BYTES`; unreadable → skipped, `memory.readable=false`) | `control='run'`, `control_set_by/at`, `mode`; commit; `Popen([sys.executable,"-m","app.cli","collect","--source",slug,"--mode",mode,"--by",actor.username,*limit], start_new_session=True, close_fds=True)`, stdio inherited; a daemon thread `proc.wait()`s and logs `collector_exited`; audit `{mode, limit, pid}`; `202` |
| `pause` | `{}` | `NOT_RUNNING` (no live run, or dead), `ALREADY_STOPPING` | `control='pause'`; idempotent (`changed:false`, no audit row) |
| `resume` | `{}` | `NOT_PAUSED`, `ALREADY_STOPPING` | `control='run'` |
| `stop` | `{force?}` | `NOT_RUNNING`; `force` when effective control is not `stop` → `STOP_NOT_REQUESTED`; `force` with no pid → `PID_UNKNOWN` | dead by rules → `mark_stuck(run, "ended by <user> from the page")` (a dead process holds no lock); else `control='stop'`; `force` and `alive` → `os.kill(pid, SIGTERM)`; `force` and pid gone → `mark_stuck(run, "stalled, process gone, stopped by <user>")`. Never SIGKILL |
| `pace` | `{delay_seconds > 0}` | `PACE_BELOW_FLOOR` (the summary names which floor: "the host asks for at least 60 s (robots.txt read 19 Sep 13:24)", "our own floor is 1 s", "a rendered source waits at least 10 s"), `PACE_ABOVE_CAP` | `delay_seconds`, `delay_set_by/at`; audit `{from, to, floor}` |
| `mode` | `{mode}` | 422, `MODE_UNSUPPORTED` | `sources.mode` |

`start_refusal(db, source, collector, mode, limit, memory, now)` is the one function the route and
`live()` call, so `start_blocked` is exactly what the POST answers; `pace_refusal` likewise. Floor =
`robots_crawl_delay` if known else `OUR_FLOOR`, then `max(that, RENDER_FLOOR_DELAY)` when rendered;
recommended = `max(robots or 0, floor)`. Deploy: `printf 'D3 %s by %s\n' "$(date -u +%FT%TZ)" "$USER"
> uploads/.collect-freeze` before the gate check, `rm -f` after health.

### D8. `live()` returns `LiveOut` (`models/schemas.py`; `response_model` on the GET)

Statements, all portable (window functions, no dialect branch): sources; running runs; the last and the
newest ok unlimited run per source (`row_number() over (partition by source_id ...)`); five audit rows
per slug; `recent` (24 h); the hours rows. Then `/proc` and `/sys`. `<= LIVE_MAX_STATEMENTS`, tested.

```
LiveMemory    used_bytes?, limit_bytes?, shed_at_bytes, start_refused_above_bytes, readable, frozen, frozen_stamp?, frozen_stale
LivePace      delay_seconds, robots_crawl_delay?, robots_read_at?, recommended, floor, floor_reason: robots|ours|render, render_floor?, cap, set_by?, set_at?
LiveRun       the D1 run columns and today's figures, plus elapsed_seconds, alive?, rss_bytes?, heartbeat_age_seconds?, requests_per_minute?, existing_missing_measured, expected_kind: exact|estimate|None, done, percent?, over_expected, eta_seconds?, stop_requested_at?
LiveCollector slug, name, platform, enabled, rendered, state (nine words), state_detail, control, control_set_by?, control_set_at?, mode_default, recheck_supported, held_listings, run?, pace, last? (the finished-table row + mode, stopped_by), refused? {since, why}, start_blocked? {code, summary}, holds_approvals, events[] {at, action, by?, detail?}
LiveOut       now, memory, collectors[] (live states first, then slug), hours[] {slug, operator, host, airports[] {iata, kind: collected|hand|none, observed_at?, source_host?, entered_by?}}, hours_by_hand[], hours_missing[], recent[], totals (a key per state + prices_24h, finished_24h, ok_24h, failed_24h)
```

`_story` gains a fourth kind, `neutral`, for `stopped` and `skipped`: the strip draws them grey.

### D9. The page (`components/collectors/LiveBoard.*`, `CollectorRow.*`, two hooks)

Acceptance is this text at 1440 and, stacked one card per collector with no sideways scroll, at 390.
`LiveTab` and `useLiveJson` leave `CollectorsPage.tsx`; `StatusChip` from the pack, never edited.

```
MEMORY  app container  1.6 GiB of 4.0 GiB  [##########.....|....]  shed at 3.4 GiB · starts refused above 3.0 GiB
COLLECTORS  3 running · 1 paused · 1 stalled · 11 idle · 4 refused · 1 off              refreshed 14:02:31
 ● running  avolta-ath   Avolta   [######......] 61% of about 1,240 · about 38 min to go
            prices 756  new 12  pace 60 s (host asks 60)  82 MiB                           [Pause] [Stop] ▸
```

The other rows carry their D6 `state_detail` and actions ("paused 2 min by rian · [Resume] [Stop]");
idle adds "Start: look for new ▾"; a recheck reads "24 of 30 checked". Expanded: mode, started by, pid,
requests, RSS, the seven counters, the pace input (`min=floor max=cap`, "host asks N" or "host asks
nothing", recommended beside it), the five events. Rules: "of about N" for an estimate, "of N" for an
exact count; never 100 while running; "about 38 min to go", never a clock time; no percent before
three units; paused shows "paused N min by X", no ETA; buttons derive from `state` and `start_blocked`
only; a refusal shows `start_blocked.summary` or the pace sentence built from `LivePace`, else the code
(D11); after Start, "started, waiting for the run" until the next poll; the freeze banner with its
stamp, "N hours old" past `FREEZE_STALE`. `useLivePoll`: one fetch in flight, the D2 intervals, re-fetch
on `visibilitychange`, the last good data kept with "not refreshed since HH:MM:SS", a 401 stops polling
with the sign-in link, `refetch()` after every `apiPost`. `useCountUp`: `requestAnimationFrame`, snaps
down, at once under `prefers-reduced-motion` (the pulse too). Hours card: operator, slug, IATA, kind, date.

### D10. Tests (SQLite, no network, a fake sleeper and clock, seconds)

`test_collector_control.py` (new): the hook unbound and bound through every D3 branch; the stop flag
landing in a 60 s wait after three slices; `RunStopped` surviving a stub's `except Exception`;
`pace_refusal` naming each floor and the cap; a SIGTERM mid-run leaving `stopped`, `stopped_by=
"signal"`, the batch committed (docstring: the four `running` rows the 19 Sep OOM left and the 24 h
the approval gate held); the stale-row check; `start_refusal` each code, **`SOURCE_REFUSED` before
`MEMORY_LOW`**, no `SOURCE_RUNNING` for a dead row; the spawn a list, `--by` from the session; the
advisory lock; `stop` and `force` on live, dead and stalled rows (`os.kill` monkeypatched); every
route as the owner (`tests/_accounts.as_user`) asserting the audit row, 403 for a developer.
`test_collector_view.py`: the progress edges; the estimate skipping a limited ok run; `state_of` from
seeded rows with a fake `/proc`, each of the nine words; the two-listing fixture (one new variant, one
price move; a lost savepoint counts 0); recheck `None`, `FetchError`, `SourceBlocked`; `live()` as
`LiveOut` within `LIVE_MAX_STATEMENTS` on 400 seeded runs. `test_writer.py`: a stale heartbeat is dead.

### D11. Drop list under time pressure, and the items the builder raises

Drop, in order, each self-contained: `stop {force}` and Stop now; the events block; `hours_by_hand` /
`hours_missing`; `useCountUp`; `existing_changed` and its price map; the reaper thread; the hours
group. Never dropped: the boundary commit, `RunStopped` as `BaseException`, the flag-not-raise handler,
the 2 s poll, the loop's own robots read, the stale-row check, `dead` from time alone, the advisory
lock, `effective_control`, `limit_n`, `start_blocked` and `state_detail` from one function. Running-list
issues to add in AW4.2 (`items.py add --kind issue --by "Stream AW4"`, to each file's owner):
`api/client.ts` should carry `summary` on `ApiError`; `refuse_if_collecting` misses the heartbeat
rule; the kill switch route audits nothing; `cmd_sweep_plan` says `paused` for it.

## Tasks, in order (commit prefix `AW4:`)

- **AW4.1 Adopt the recorded design** (done by the panel; 15 min): register the tasks on `/plan`, read
  D1 to D11 against the tree; no code.
- **AW4.2 The control plane** (3 h): D1 rehearsed up, down, up on `dfp-devdb`, one head; D2, D3, D4,
  D7 with the six `PERMISSION` keys; the control and route tests; the four issues of D11.
- **AW4.3 The live read** (2.5 h): D5, D6, D8; the view, counter, procinfo and writer tests.
- **AW4.4 The page** (3 h): D9; `main/check.sh` green; screenshots at 1440 and 390 in headless Chrome
  (the cookie over CDP) against the SPA dev server and a local API on a restored copy with seeded rows
  in each state and a throwaway owner password; files in the scratchpad.
- **AW4.5 The W2 plan and the documents** (1.5 h): `.logs/runs/window-2026-09-19-w2-live-page.md`
  (`type: run`), each step with its `psql` check and a fail line: the marker absent; `shopify-yul`,
  pace 3 s, Start `recheck --limit 30` → `running` within one poll with pid, heartbeat, `expected_total
  30`; percent and ETA after three requests; Pause → `pausing` then `paused` within `CONTROL_POLL` plus
  one poll, `requests_made` still, heartbeat moving; Resume; pace 6 s → requests halve; pace 0.5 s →
  `PACE_BELOW_FLOOR`; Stop → `stopped`, `stopped_by`, prices kept; Start while running →
  `SOURCE_RUNNING`; two Starts in a second → one run, a `collector_locked` line; `stop {force}` fresh →
  `STOP_NOT_REQUESTED`; `discover --limit 30` → `existing_missing_measured false`, percent 99 then `ok`;
  the marker touched → `COLLECT_FROZEN`. Docs: `RUNBOOK.md` Collecting (page actions replace `docker
  exec`; the modes; the pace rule; the freeze stamp in the Deploy step; windows name the page
  actions); `COLLECTORS.md` a paragraph; `ARCHITECTURE.md` a sentence, `docmap.py --write`; `CHANGELOG.md`.

## Owns, and must not touch

Owns: `app/services/collectors/control.py` (new), `fetch.py` and `robots.py` (the hook call and
`note_robots` only), `ingest.py` (the loop, `_listings`, counters, the lock, the handler, `is_stuck`;
never `_resolve_product`, `_resolve_listing`, `resolve_brand` or any rule), `services/procinfo.py`
(new), `collector_view.py`, `routers/collectors.py`, `access.py` (the six keys), `models/catalog.py`
(columns only), `models/schemas.py`, `config.py`, `cli.py` (`cmd_collect` args, `cmd_status` words),
the new migration, `tests/test_collector_control.py`, `test_procinfo.py`, `test_collector_view.py`,
`test_writer.py` (live and stuck only), `web/src/components/collectors/*`, `web/src/lib/useCountUp.ts`,
`useLivePoll.ts`, `pages/CollectorsPage.tsx` (the `LiveTab` removal), `main/docs/RUNBOOK.md` (Collecting,
the Deploy step's freeze lines; after AW1's), `COLLECTORS.md`, `ARCHITECTURE.md`, `CHANGELOG.md`, the W2
window entry, this brief, `.logs/handoff.md` (prepend), `import/*.json` via `plan-set.py` and `items.py`.
Must not touch: staging's and production's databases; `.env`, `.app.env`, `.bw-auth.env`,
`docker-compose.yml`; the collectors' parsing, `registry.py`, `keying.py`, `normalize.py`, `merges.py`,
`identity.py`, `decisions/*`, `REVIEW-PROCESS.md`, `services/hours/*`, `routers/sources.py`,
`api/client.ts`, the vendored packs, `main/check.sh`, `docs-check.sh`, `test_route_inventory.py`,
`test_access.py`, `.logs/runs/sweep-2026-09-19b/`, `uptime.log`, `notes/`, other AW briefs' files; any
network fetch beyond one `robots.txt` per host.
