---
name: v0370-commune-drop-detect
description: "2026-09-06 SHIPPED v0.37.0 — the across-commune boundary now keys on the DROP FILE, not on the Write tool; claude-spt-bs#25. Carries the event-vs-state trigger rule and the ResidentService auth gap (spt-bs-releases#275)."
metadata: 
  node_type: memory
  type: project
  originSessionId: df34fdab-4fe5-4743-9b09-62292e3ec385
  modified: 2026-09-06T07:04:59.661Z
---

**2026-09-06 — claude-spt v0.37.0 SHIPPED, `claude-spt-bs#25` DONE** (release roundup names it).
Operator report: agents increasingly write their across-commune with Bash, and no boundary fired.

## The defect, and why it is a familiar shape

`is_commune_write` was `tool == "Write" && file_path.ends_with("<id>-commune.md")` — the trigger
inspected the **tool call's arguments**, so it was keyed on the *instrument*. A commune written with
a Bash heredoc, `cat >`, `Edit` or python fired nothing: the commune ingested, the context rebuilt,
and the session it was meant to end just kept running. Silent at both ends.

spt-core's model was always the opposite — a commune is a **file-drop**, "any harness that can write
a file can commune", `[session].commune_dir` being the declared seam. **The drop is the contract;
the tool is not.** Same family as [[list-vs-predicate-assertions]]: a rule keyed on the instances
that existed the day it was written stays green until the case nobody enumerated arrives.

## ⭐ THE RULE: trading an EVENT trigger for a STATE trigger re-fires it

A Write tool call is an *event* — observed once, and being-observed-once is a property of the event,
not of anything you wrote. A drop file is a *state*: it persists until core ingests it (>15s, many
tool calls). **Every state-shaped trigger re-fires unless something remembers.** Without a stamp the
detector arms on every hook for the whole window: a second wake-ARM self-send and two post-clear
sequences, which *blend* rather than fail loudly ([[boundary-rename-v0141]]).

`state/commune-seen/<eid>.stamp` records the **last EXAMINED** identity (`<mtime_ms>:<len>`), not the
last armed one — a marker-less commune is the common case and would otherwise be re-read (20-100KB)
on every tool call while never arming. Both halves are tested, including a **stat-not-read** test:
a detector that is correct but re-reads is a regression nothing downstream would ever notice.

## ⭐⭐ THE PLAN WAS WRONG ABOUT ITS OWN CLEANUP STEP

The JIT plan said "clear the stamp at `SessionStart(clear)`". That is a **clear loop**: post-clear,
an un-ingested drop plus a cleared stamp re-arms immediately. Caught while wiring it, not by a test.
The stamp now clears only on the drop's *absence*. A plan's cleanup step deserves the same "what
happens on the next tick" reading as its main path.

## ⭐⭐⭐ Two builders for one file

`write_commune_file` built the path with `PathBuf::join` while the detector built a string — so the
writer and the reader could disagree about a separator and the stamp would never match the drop the
adapter itself had just written. Collapsed to one builder. Also: the Recorder's `write_spill` was
modelled as a log entry that left **no readable file**, so the shortcut's own stamping could never be
exercised by any test. A mock that cannot show the artifact cannot fail when the code misreads it.

## The alternatives, rejected with reasons (they do not survive in the outcome)

- **io funnel `COMMUNE` kind** — the *right* predicate, and `api.md` names "wake-marker-class
  constructs over commune and IO events" as its motivating case. But it fires only when **ingest
  completes** (>15s, operator-measured), when no hook is running; it is authenticated; and its
  payload caps at 16KB with `truncated`/`digest_seq`, which our communes exceed.
- **A `[service]` ResidentService** — the only process alive between turns, but `$defs/Service` says
  it has "no perch, no identity, and no address" while `api io-events` needs `--session-id` or a
  capability `--token`. **Filed as `spt-bs-releases#275`** (hand-filed via `gh`: no alchemy instance
  here is bound to that repo, so the mint never ran — deployah typed it `addition`). Filed
  **NOT BLOCKING**, and said so in the body: filing a request you have already routed around, without
  saying so, misprices it for the next person.

**ACCEPTED RISK, in the call-site comment so it can be falsified:** this holds only while core's
ingest is *slower than a hook cycle*. Sub-second ingest would let a Bash-written drop vanish unseen,
and the funnel/service path becomes necessary.

## Process notes worth keeping

- **deployah pointed me at `docs/MANIFEST.md` in the spt-core repo** as the authoritative `[service]`
  prose. Declined — never reading that tree is the whole constraint. His having to reach for an
  internal file to answer a builder's question *is* the measurement that justified his docs port
  (`spt-bs-releases#274`, v0.67.1). See [[node-local-book-gate]].
- **`manifest.schema.json` is a documentation surface**, not just a validator: it carried the entire
  `[service]` contract in prose while the published book had none of it.
- The **leak guard** caught a private ticket ref in a `hook.rs` comment. Its own test printed the
  LEAK header while `grep -i leak` dropped the next line — the one naming the file. Widened to `-A2`.
  See [[mirror-guard-scope-and-excludes]], [[msys-grep-instrument-traps]].
- A `\n` inside a python heredoc collapsed to a real newline again ([[v0340-nowsignal-pretool-agent-output]]);
  used `chr(92)`. Two Bash heredocs also died on quoting — write the script with the Write tool.

## State

520 tests, gate PASS, `traceable-reqs check` exit 0. `REQ-COMMUNE-DROP-DETECT` +
`REQ-HAZARD-COMMUNE-DROP-REARM` (KNOWN-HAZARDS 9.1), `REQ-DIST-CHECKPOINT-COMMUNE` amended (its
detection half superseded). **Seven mutations, all caught, each by its own test and only its own.**
Floor unchanged 0.66.0; skeleton unchanged 0.1.18 (brief text ships in `adapter.spt`). Published,
notes verified at the consumer, mirrored (`3e0b8e9`), installed here.
