Skip to content

ADR 0154 increment B: synchronous captured-downstream reply (reply_from) - #119

Merged
wshallwshall merged 24 commits into
mainfrom
adr0154-sync-reply
Aug 2, 2026
Merged

ADR 0154 increment B: synchronous captured-downstream reply (reply_from)#119
wshallwshall merged 24 commits into
mainfrom
adr0154-sync-reply

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Implements increment B of ADR 0154, authorised by the owner. Naming reply_from turns an inbound HTTP listener from fire-and-forget into a proxy: the turn blocks until the named outbound's reply is captured and committed, then returns it as the response body.

Increment A (intake auth + the D7 gate) shipped in #109.

10 commits, riskiest last

Every primitive lands and is individually tested before anything awaits it. The wait loop lands dark; 9f4b9218 is the single commit where reply_from becomes observable, and the intended rollback point.

b4fd4d18 reply_wait_state — metadata-only poll read, all 3 backends
defeba6e record_message_event — the public message-event writer
bbbf0f0f ReplyRendezvous — 15 race-matrix tests
2c9ca79d the injection seam (InboundReply, SyncReplyResolver)
a8518de0 the six Http() settings + factory validation
472b9a62 cross-registry validation
3d77151f listener wire primitives
d569bea6 the resolver + wait loop — 17 outcome branches
9f4b9218 the wire — feature goes live
87407520 runner injection + stop() pre-close drain

The correctness properties that matter

The committed row is the sole authority. Every in-process signal is a latency hint; the loop decides only from what the store says. That is what makes it correct under engine sharding, HA failover, every claim mode, and any race between the capturing worker and the reader.

Terminality by exclusion, never enumeration. An enumerated list omits PROCESSED — exactly what the finalizer sets when a sibling handler delivered while the awaited Send was never emitted. Enumerating hangs that turn for the full reply_timeout. A test iterates every MessageStatus member so a future one forces an explicit decision.

An empty row list is not evidence of exclusion. Routed rows carry a NULL destination_name, so a sibling still upstream is structurally invisible to the destination-keyed query. Reading empty as excluded is the 502-for-a-message-we-then-delivered defect the ADR diagnoses in its own revision 1.

degraded is never folded into timeout. rate(timeout)/rate(total) is the proxy API's error budget; counting our own store errors or a full rendezvous as the partner failing to answer would corrupt the one number an operator pages on.

stop() drains before closing writers. A 503 written after close() lands on a dead transport and asyncio typically discards it with no exception at all, so the demoted caller would get a bare reset. ADR revision 1 promised both this and the old ordering; they are mutually exclusive.

Where this diverges from the ADR, and why

  • reply_from implies capturing the partner's content-type (owner ruling). reply_content_type defaults to "passthrough", which D4 says requires content-type in the target's capture_response_headers — a setting that defaults to None. The ADR's own headline shape would have raised at check. Implemented as an idempotent, observable normalisation, so the implied header shows in /metadata and graph --json.
  • No bytes-body sibling for build_response. Every capture path already decodes with errors="replace" before the store and the column is encrypted TEXT, so non-UTF-8 fidelity is destroyed at capture — a bytes type would promise a faithfulness the pipeline cannot deliver.
  • The effective ordering/max_attempts refusals are re-run at start. The offline arm skips them when its caller cannot supply [delivery], so the runner is the unconditional backstop.

Verification

9723 passed, 818 skipped, 0 failed; ruff and mypy clean (the 21 remaining mypy errors are pre-existing on main, all in optional-extra modules). AC-17's fence still holds: transports/ imports neither store/ nor pipeline/.

Deliberately not in this PR

  • AC-18 observability — the store writer and both kinds landed in defeba6e, but nothing emits them and no metrics exist yet. The feature runs; it is not yet visible in the message timeline or Prometheus.
  • The in-process hints (D3's latency optimisation) — the poll is correct without them. The static guard forbidding a signal from the fused route/transform executor threads ships with them, so until then that constraint is documented in reply_wait.py but not enforced.
  • capture_error_responses — on an ordinary partner 4xx the caller gets fixed non-PHI 502 JSON, never the partner's error body. Owner-accepted for now and documented.

⚠️ CI note: the suite is now ~22 min locally, up from ~13. Given windows-2025 failed at 21m and 17m58s against a 26-minute step cap earlier today, this branch has a real chance of hitting that cap. If it does, that is the pre-existing runner problem, not this code.

🤖 Generated with Claude Code

wshallwshall and others added 11 commits August 1, 2026 05:15
ADR 0154 D3, on all three backends. Nothing calls it yet.

Returns the message's own status, the awaited destination's outbound row states,
and the highest committed response_seq — no body, no decryption. Deliberately NOT
built on outbox_for: that is a SELECT * fed through _decode_row, so it would
decrypt last_error and pull payload ciphertext on EVERY tick of a poll loop, and
read PHI this path has no business touching. Modelled on pending_depth instead.

Two design points are correctness, not style, and both are pinned by tests.

The message status is returned ALONGSIDE the row states because a routed row
carries a NULL destination_name — only handler_name is set. So a sibling
handler still upstream is structurally invisible to the destination-keyed query,
and an empty row list does NOT mean the message was excluded. Concluding that it
does is the defect ADR 0154 revision 1 shipped: a 502 returned for a message the
engine then delivered normally.

message_is_terminal is defined by EXCLUSION — not in (RECEIVED, ROUTED) — never
by enumeration. An enumerated list (UNROUTED/FILTERED/NOT_DEPLOYED/ERROR) omits
PROCESSED, which is exactly what the finalizer sets when a sibling handler
delivered while the awaited destination's Send was declined, filtered out, or
never emitted by a code-first Handler. Enumerating hangs that turn for the full
reply_timeout instead of failing fast. test_terminality_covers_every_message_status
iterates the enum, so a future member forces an explicit decision rather than
silently inheriting the bug.

latest_response_seq excludes ADR 0021 ack_sent rows: the inbound ACK we returned
must never satisfy a wait for the partner's reply, or the listener echoes our own
ACK back to the caller.

Safe alone: additive, no caller. The QueueStore protocol member is what proves
backend parity — an unimplemented backend is a mypy error at open_store's three
return statements, and mypy is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0154 D8, on all three backends, plus the reply_returned / reply_timeout
kinds. Nothing emits them yet.

_event is private to each backend and is only ever called inside a store-owned
transaction, so before this neither pipeline/ nor transports/ could record a
disposition event at all. The ADR's justification holds; what it understates is
the cost — record_view is already exactly this method hard-wired to "viewed", so
each backend is a ~10-line parameterisation of a shipped shape, transaction
handling included.

It lands on QueueStore rather than AuditStore: message_events is the per-message
DISPOSITION timeline, the sibling of record_connection_event, not the
tamper-evident audit_log. That placement also lets pipeline/ reach it through the
store it already holds, with no cast.

The kind is validated at runtime, and that guard is not belt-and-braces. The
shipped static check AST-walks the backends for a CONSTANT first argument to
_event; this method forwards a variable, so it is blind to every kind written
through here and passes vacuously. Without the runtime check a typo'd kind would
write silently and CI would stay green.

reply_timeout joins the compliance floor: it is the one row that explains a "we
called you and got a 504" complaint, so an instance that thinned message_events
to errors/off would lose exactly the record it is later asked for.
reply_returned is the routine counterpart and stays thinnable, like delivered.

docs/PHI.md row 6 states the floor TWICE and both statements were maintained by
hand with nothing checking either against _AUDIT_FLOOR_EVENTS — a kind could join
the floor in code while the doc kept promising a shorter list, to an operator
deciding what is safe to thin. test_every_audit_floor_event_is_named_as_such_in_row_6
closes that gap; it is not required by this change and would have been worth
adding regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0154 D3. Stdlib asyncio only, no importer yet.

Every signal is a LATENCY HINT, never an answer: a woken turn re-reads the store
and decides from the committed row. That is what makes the module safe to be
wrong — a hint that never fires costs latency, a hint that fires spuriously costs
one extra read, and neither can produce an incorrect HTTP response. signal() and
fail() are therefore deliberately INDISTINGUISHABLE to the waiter; if they
resolved the wait differently the signal would have become data, which is the
mistake this design exists to avoid.

Two properties are structural rather than left to caller discipline, because both
fail silently and load-dependently:

arm() is a context manager, not an arm/disarm pair. An entry that outlives its
turn — a missed disarm, a cancellation between arm and try, an early return —
accumulates until the cap, after which EVERY caller resolves degraded while the
store is perfectly healthy and no error appears anywhere. The release is not the
caller's to forget. Tested across the normal, timeout, exception and cancel paths.

hint() consumes the wake before returning. An asyncio.Event stays set once set,
so a waiter looping on an unconsumed one would stop sleeping entirely and spin at
full CPU for the rest of reply_timeout — and it would do so precisely in the
legitimate case that drives repeated wakes: a message fanned out to several
handlers, where each sibling's progress wakes us and the store correctly says
keep waiting. Consumed on the timeout path too, so a hint landing in the race
with wait_for expiring cannot survive to skip the next sleep for free.

RendezvousFull is deliberately not a timeout: a timeout claims the partner did
not answer, which would be a lie about the partner and would corrupt the
rate(timeout)/rate(total) SLO series an operator pages on.

The module docstring records the thread-affinity constraint prominently:
Event.set() and call_soon are not thread-safe, and the engine has real off-loop
paths (the fused route/transform bodies) that look like tempting hook sites.
Calling in from one usually appears to work while intermittently dropping
wakeups. The static guard for that lands with the hook sites in C12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0154 D2 — ReplyOutcome, InboundReply, SyncReplyResolver, and the
SourceConnector.sync_reply attribute. Declarations only; nothing sets it.

Correction to the ADR: D2 calls this "a fourth runner-injected attribute". It is
the SIXTH — on_connection_event, content_type, processed_ledger, on_intake_audit
and intake_rate_limiter precede it, the last two added by increment A.

This is the seam that squares increment B with AC-17. Increment B's whole job is
returning bytes that came out of the store, while transports/ is CI-forbidden
from importing store/ or pipeline/. A runner-owned resolver injected after build
is what makes that legal, and the C0 fence proves it stayed legal.

The resolver takes exactly one argument, the committed message_id. That is
ACK-on-receipt enforced STRUCTURALLY rather than by convention: the id does not
exist until the body is durably committed to the ingress stage, so there is no
shape of this call that observes an uncommitted message.

InboundReply.__repr__ omits the body. The structural PHI rule — reply-derived
bytes never reach an exception, log, connection_event.reason or
message_events.detail — covers deliberate use; the repr covers the accident,
because a frozen dataclass's default would put the partner's reply into any log
line, traceback or assertion that interpolates the object. That leak survives
review precisely because nothing looks wrong at the call site. Tested against
str(), f-strings and .format().

body is str, not the bytes the ADR asks for. Every capture path already decodes
with errors="replace" before the store, DeliveryResponse.body is str and
response.body is encrypted TEXT — so non-UTF-8 fidelity is destroyed at capture
and a bytes type here would promise a faithfulness the pipeline cannot deliver.

degraded stays a distinct outcome from timeout: rate(timeout)/rate(total) is the
proxy API's error budget, so counting our own store errors as partner timeouts
would corrupt the one number an operator pages on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…idation

ADR 0154 D4 — the six synchronous-reply knobs on Http(), plus every refusal
decidable from one factory call. Nothing reads them yet; reply_from defaults to
None, so every shipped configuration stays byte-identical (AC-8).

Factory-local placement is the point: no store, no posture, no registry, so the
refusals fire identically in `messagefoundry check`, in dry-run, and through the
connections.toml desugar, which routes through this same factory. The
cross-registry half — reply_from naming a DEPLOYED outbound, that outbound
capturing responses, the passthrough content-type requirement, and the effective
ordering/max_attempts refusals — is not knowable here and lands in the next
commit against build_check_registry.

Refuses a knob set without reply_from, the same defect class as a credential
configured with intake_auth="none": each is inert without the mode switch, so the
config would read as configured while doing nothing. The error names the offending
knob so the fix is visible from the message alone.

The defaults that refusal compares against are DERIVED from Http's own signature
rather than copied into a table. A hand-maintained copy would go stale the first
time a default changed, and the failure would be silent — the guard would simply
stop firing for that knob, which is the wrong direction for a guard to drift.
Pinned by a test that re-derives it independently.

Also refuses a zero or negative reply_timeout / reply_write_timeout. Both bound a
BLOCKED HTTP turn, so zero is not "unbounded", it is a turn that cannot succeed —
and an operator writing 0 almost certainly means unbounded, which is worse than
what they would get.

None of the six names is credential-shaped, so no _NOT_A_SECRET classification is
required — verified against test_connection_api's scan, which stays green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0154 D4's other half — the facts one Http() call cannot know because they are
about the OTHER connection. Runs with no store, so it fires at `messagefoundry
check` and in dry-run exactly as at serve.

The ordering/max_attempts pair is the landmine, and it is a landmine precisely
because the naive test passes. OutboundConnection.ordering defaults to None
meaning inherit, and retry defaults to no RetryPolicy object at all, so a literal
`ordering == FIFO` check passes cleanly for the overwhelmingly common shape —
the exact shape the refusal exists to catch. Both are therefore read as EFFECTIVE
values against the resolved [delivery] defaults, and the tests assert against a
graph that declares NOTHING.

Both are refusals, not warnings, because together they make the headline use case
unserviceable: a FIFO lane drains one message at a time and blocks the head on
failure, so concurrent HTTP callers serialise behind one partner round-trip and a
single stuck message times out every caller; retry-forever is incoherent with a
caller that gave up 30 seconds ago.

[delivery] is threaded into build_check_registry rather than guessed. When a
caller cannot supply it the arm is SKIPPED, not approximated — a guessed refusal
would reject working configurations — and the runner re-checks at start where the
resolved values always exist. checks.py passes it, so the commit/CI gate has it.

reply_from now IMPLIES capturing the partner's content-type (owner ruling),
resolving a contradiction in the ADR: reply_content_type defaults to
"passthrough", which D4 says requires content-type in the target's
capture_response_headers — a setting that defaults to None on all three capable
factories. The ADR's own headline shape would have raised at check.

The implication is an explicit, idempotent normalisation of the resolved graph
rather than a hidden runtime fallback, so the implied header shows up in
/metadata and graph --json like any other captured header. An implication nobody
can observe is indistinguishable from a bug. It preserves the operator's own
list, matches case-insensitively, and leaves a pinned literal MIME type alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0154 D5, the parts with no dependency on the resolver. Still inert: reply_from
defaults to None, so the shipped 202 path is byte-identical (AC-8).

build_response now omits entity headers on a 204. RFC 9110 §15.3.5 forbids a body
there, and Content-Length beside it is at best noise and at worst a parser
tripwire — some clients treat an entity header on a bodyless status as a framing
error. This is ordinary traffic rather than an edge case: reply_on_empty="204" is
the default answer for a deliberately empty partner reply. extra_headers still
ride a 204, since Retry-After and friends are not entity headers.

_respond's drain budget is now chosen rather than constant. The shipped 202 path
keeps _CLIENT_SHUTDOWN_GRACE — a few dozen bytes, bounded by that constant in
increment A. A reply_from inbound uses its own reply_write_timeout, because it
carries a PARTNER-SIZED body to a possibly slow reader and a receipt-sized budget
would truncate a legitimate large reply.

The two are not interchangeable in the other direction either: reply_write_timeout
defaults to 30s against a 5s shutdown grace, so one drain could outlive the whole
teardown by 6x. stop() clamps it to a sub-budget — that lands with the pre-close
drain phase, and the docstring says so rather than leaving the gap implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0154 D3. Injected nowhere yet — dead code by construction, which is the point
of landing it separately from the listener path that will call it.

The committed row is the sole authority. Every in-process signal is a latency
hint; the loop decides only from what the store says, which is what makes it
correct under engine sharding, HA failover, every claim mode, and any race
between the capturing worker and this reader.

TOTAL by construction: every exit is an InboundReply, nothing propagates. A store
error resolves degraded rather than raising, because a raise would surface as a
500 and lose the committed message's disposition from the operator's view.

degraded is kept strictly distinct from timeout — for store errors AND for a full
rendezvous. timeout means "the partner did not answer", and
rate(timeout)/rate(total) is the proxy API's error budget, so folding our own
failures into it would corrupt the one number an operator pages on.

Terminality is read two ways and never conflated. A PROVEN-terminal row state
(dead/cancelled) fails fast; the ABSENCE of rows never does, because a sibling
handler still upstream leaves the list empty — routed rows carry a NULL
destination_name. Reading empty as excluded is the 502-for-a-message-we-then-
delivered defect, and there is a test for exactly that interleaving. PROCESSED is
covered too, via exclusion rather than enumeration: it is what the finalizer sets
when a sibling delivered while our Send was never emitted, and an enumerated list
would hang that turn for the full reply_timeout.

The poll period widens with live-waiter count rather than being constant. On
SQLite, reads share a FIXED pool of four connections with the admin API, console,
retention and alert sweeps, so 256 blocked callers at a constant floor would be a
self-inflicted denial of service against the very store they are waiting on. The
ADR asserts this is necessary without specifying it; this specifies it, bounded
so worst-case added latency stays predictable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0154 D5. THIS is the commit where reply_from becomes observable: everything
before it was inert. Reverting this alone disables the feature and leaves C1-C8
dead but green, which is the intended rollback point.

Reached only when reply_from is set AND the runner injected a resolver, so an
inbound without it never enters this path — that is what makes AC-8's "unchanged"
structural rather than aspirational, and the shipped 202 suite proves it on a
real socket.

Every row of D5's outcome table maps to exactly one outcome and back. Only reply
and rejected carry partner bytes — the two the caller actually asked to be
proxied; every refusal and timeout body is fixed, non-PHI JSON carrying the
message_id so a caller can reconcile later.

Two mappings are deliberate rather than obvious. no_route answers IMMEDIATELY
with the timeout status instead of waiting out the budget: the message is already
terminal, so blocking would burn the caller's patience for nothing. shutting_down
is 503 + Retry-After and NEVER 504, because on an HA demotion the new leader is
about to deliver the message — claiming the partner timed out would be a lie
about a message still in flight.

The HTTP status is never a second disposition channel. Whatever goes out here the
message stays committed and keeps flowing; the finalizer alone decides its
disposition. A 504 cancels nothing and a 200 completes nothing.

A declined handler is now 422 on the sync path. The 202 path answers "202 without
a message_id", which is a lie to a proxy client; a caller blocked on a reply
deserves to be told the submission failed. Post-record, so count-and-log holds —
the handler already wrote the message with status ERROR.

A hostile partner Content-Type cannot take the turn down. The header guard
rejects CR/LF, and rather than 500 the caller on a value the PARTNER controls,
the turn falls back to our own content type and still returns the body. Tested
with an injection attempt.

No reply-derived bytes reach the log: the debug line carries the outcome enum,
the destination, the seq and waited_ms, and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ns on stop

Wires C1-C9 together: the runner now builds a resolver per reply_from inbound and
injects it, so the feature actually activates. None on every other inbound, which
keeps the shipped 202 path byte-identical (AC-8).

The runner re-runs the cross-registry validation HERE, where [delivery] is
resolved. build_check_registry's offline arm skips the effective
ordering/max_attempts refusals whenever its caller could not supply those
defaults, so this is the backstop that makes them unconditional: a graph that
would serialise every concurrent caller behind one FIFO lane fails to START
rather than degrading silently under load, and ADR 0031 isolates that to the one
connection.

The rendezvous is per-runner. The waiter and the capturing worker are the same
process by construction — under HA the graph runs on the leader only — so
process-local is the right scope, and a shard that never sees a signal is merely
slower, never wrong, because the store is the authority.

stop() gains the pre-close drain phase, and its POSITION is the point (AC-10).
Waking blocked waiters happens BEFORE the client writers are closed. ADR revision
1 promised both the 503 and the existing close-first ordering; those are mutually
exclusive. A 503 written after close() lands on a dead transport, and post-close
asyncio typically DISCARDS the write with no exception at all — so _write_safely's
except arm never even sees it and the demoted caller gets a bare connection reset
instead of the answer the HA argument depends on.

reply_drain is a plain callable on SourceConnector, not the rendezvous object, so
transports/ still imports neither store/ nor pipeline/ — the AC-17 fence proves
it. No-op when nothing is armed, so teardown for a listener without reply_from is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall
wshallwshall enabled auto-merge (squash) August 1, 2026 13:29
wshallwshall and others added 5 commits August 1, 2026 08:30
The message_events half and the counters. The /metrics EXPOSITION is not wired
yet — see below, stated rather than implied.

reply_returned covers reply, rejected AND empty: the partner answered, and a
negative or deliberately blank answer is still the thing the caller was waiting
for, so it belongs in the same row rather than looking like nothing happened.
reply_timeout carries its fallback status, because an operator reading that row
needs to know what the caller actually got.

Outcomes that already have their own disposition trail — a dead/cancelled row, an
UNROUTED message, a degraded read — mint NO second row. Duplicating them would
make the timeline read as two events where one thing happened.

detail carries names, counts and timings only. The structural rule is that
reply-derived bytes never leave the resolver's return value, so nothing
interpolated here can be a body fragment; the tests assert the absence directly
rather than trusting the rule.

Recording is fail-soft, and that is a decision rather than caution: it runs after
the wait has resolved, so a store hiccup must not turn a perfectly good partner
reply into a 500. Losing a diagnostic row beats losing the reply it describes.

Metrics are labelled `status`, not the ADR's `outcome` (owner ruling).
api/metrics.py states a CLOSED label allowlist as a PHI contract and
test_metrics_exporter asserts it; the outcome enum is a fixed non-PHI constant
set, so reusing `status` is honest rather than a workaround and keeps a
deliberately closed contract from widening for a label that adds nothing. A test
pins that `outcome` has NOT been added to the allowlist.

The counters live with the runner that owns the resolvers and are exposed through
the public RegistryRunner.sync_reply_metrics() accessor — api/metrics.py builds
every family per scrape from engine.store alone and has no view of the runner,
while transports/ may not import api/ (AC-17).

STILL OUTSTANDING: the exporter does not yet READ that accessor, so the three
series are counted but not yet scrapeable. Engine exposes no runner attribute, so
wiring it needs a seam that did not warrant guessing at at the end of this pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes AC-18. The three D8 series are now scrapeable, not just counted.

Correction to the previous commit's message: it said "Engine exposes no runner
attribute". That was wrong — Engine.registry_runner is a PUBLIC property, so no
new accessor was needed and nothing reaches into a private attribute. I recorded
an obstacle I had not actually verified; the wiring turned out to be three small
edits.

Labelled `status`, not the ADR's `outcome`. api/metrics.py states a CLOSED label
allowlist as a PHI contract and test_metrics_exporter asserts it; the outcome
enum is a fixed non-PHI constant set, so it rides an existing label rather than
widening a deliberately closed one. `degraded` stays a distinct label VALUE —
rate(timeout)/rate(total) is the proxy API's error budget, so our own store
failures must never read as the partner failing to answer.

The waiters gauge is tracked PER CONNECTION rather than read off the rendezvous.
The rendezvous is shared by every inbound in the runner, so publishing its total
under a {connection} label would attribute one listener's load to all of them —
worse than no gauge at all for the number operators size capacity on. Incremented
and decremented in a try/finally, so a client that hangs up mid-wait cannot leak
a permanently-blocked waiter into it; there is a cancellation test for exactly
that.

The families are ABSENT rather than zero on an instance with no reply_from
inbound, so a constant 0 across every fleet that never uses this cannot become
alert noise — the same choice ADR 0114's degraded gauge made, and tested.

The exposition test asserts on SAMPLE names, not Metric.name: prometheus_client
strips a `_total` suffix from the latter while the exposed sample keeps it, so
asserting the family name would have passed while the scrape showed something
else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…its thread affinity

Completes increment B. The wait loop was already correct without this — the hint
only collapses a reply that has ALREADY committed from "next poll" to "now".

The signal sits strictly after complete_with_response returns NORMALLY. Under
SQLite group commit that call enrols in a shared batch whose future resolves
post-commit, so a signal there is committed-authoritative; in a finally, or
before the await, it would fire on a transaction that may have rolled back.

It is NOT placed where the ADR says. D3 names the site beside
_wake_lane(Stage.RESPONSE, reingress_to) — but that call is nested under
`if reingress_to is not None`, and a reply_from outbound never re-ingresses, so a
hint there would be unreachable dead code that looked correct in review.

The static guard is the mitigation for the one hazard rated CRITICAL.
asyncio.Event.set() and call_soon are NOT thread-safe; called from a worker
thread they usually APPEAR to work and intermittently drop the wakeup, hanging an
HTTP turn to its full reply_timeout under load. The tempting hook sites are
exactly the unsafe ones: _run_fused_route and _run_fused_transform carry a
disposition line that reads like the obvious place to signal from, and both are
dispatched onto a ThreadPoolExecutor. They are SQL-Server-only behind a
default-off flag, so the normal PR leg would never execute a violation even if
one were added — which is precisely why this is static rather than functional.
A planted-violation test proves the guard actually fires.

Two tests cover the hint itself. That it shortens the wait is asserted as a READ
COUNT, not a wall-clock margin — a timing assertion there would fail on a slow
runner for reasons unrelated to the code. And that a MISSING hint still returns
the reply, just later: that is the property making the whole design safe, since
an engine shard that never sees the signal is slower, never wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wshallwshall added a commit that referenced this pull request Aug 1, 2026
test_backlog_status_check::test_the_real_backlog_satisfies_the_invariant fails on
origin/main at ea05525 itself. #320 (filed in #117) opened with a `📋 Filed …`
banner, which is not one of the five the invariant accepts (SHIPPED / DECLINED /
RETIRED / Re-scored / Status). An invented emoji instead of the defined vocabulary.

It fails in 0.6s inside every required test leg, so it red every PR that compiles
the suite -- already blocking auto-merge on #119 and would have blocked #118.

Corrected to `🚧 Status: OPEN INVESTIGATION …`, which is what the item is. The
banner now also carries the two facts a reader needs: measurement tooling landed
in #118, and the decisive experiment is blocked on an unregistered self-hosted
WS2025 runner.

Coverage hole recorded, not fixed here: #117 was docs-only, and ci.yml's `changes`
job short-circuits that case (code == 'false' skips install/lint/type/test), so
the guard that polices BACKLOG.md does not run on a PR that only changes
BACKLOG.md. The one class of change the invariant exists to catch is the class
that skips it. Follow-up: either count docs/BACKLOG.md as `code` for the
short-circuit, or run the ledger/backlog guards in a cheap always-on leg.
wshallwshall and others added 6 commits August 1, 2026 10:00
mypy caught what I did not: OutboxItem.destination_name is str | None, and the
hint passed it straight through. It is non-None everywhere this path runs —
NULL on ingress/routed rows, set on outbound ones — but a hint keyed on None
would silently match no waiter rather than fail, which is the failure mode this
whole design works to avoid.

Missed because the pre-commit check for the previous commit was backgrounded and
only its pytest tail was read, so the mypy line scrolled past unseen. The lesson
is about the verification, not the type: a check whose output nobody reads is not
a check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wshallwshall added a commit that referenced this pull request Aug 2, 2026
The ADR-0154 session re-ran #119's windows-2025 leg on the SAME commit
against the SAME 26:00 cap. Attempt 1 was killed at the cap; attempt 2
concluded success. Same code, same config, same ceiling, two outcomes.

This closes the one gap in the case for raising the cap: it rules out "that
PR's tests are just slow". The leg was not failing, it was coin-flipping
against the ceiling -- which is precisely the state the ubuntu note above
already names, now demonstrated rather than argued.

It also disposes of "re-run it and see" as a diagnosis. A green re-run at
26:00 does not show the suite fits; it shows that runner was fast enough
that time. Recorded in the comment so the next person reaching for a retry
knows what a green retry does and does not prove.

Evidence contributed by the session holding #119, which is deliberately
holding its branch update until this lands so it re-rolls under the raised
cap rather than spending another coin flip at 26.

Merged main (8f01cef, #120) in the same push: #131 had gone BEHIND, which
is the stall this PR exists to report -- the fix for the cap has to survive
the cap, and the fix for silent stalls can itself stall silently.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…e hard way

Announce-on-join introduces a session; it does not let an established one push an
operational notice. That increment is deferred, and on 2026-08-01 six sessions
rehearsed it by hand for four hours. Three constraints fell out, recorded so the
next attempt does not rediscover them:

- A broadcast needs an EXPIRY or a predicate the RECIPIENT can evaluate, never a
  promise from the sender. A merge freeze shipped with 'lift when #119 merges';
  #119 died on an unrelated CI timeout, so five sessions held on a condition that
  could not arrive and a second round was needed to retract it.
- 'Don't do X' is the wrong primitive when automation already has X armed. The
  freeze asked for restraint while six PRs had auto-merge ARMED and would have
  landed with nobody clicking anything. The right ask was an action: disarm.
- Coordination a tool cannot read does not count. Two sessions agreed IN WRITING
  to hand over a file and the gate still refused, because the agreement was prose
  and the gate reads git.

Field data from the sessions that lived it, not speculation.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…s that can never merge (#131)

* ci: report pull requests that are green, armed, and can never merge

Measured on this repo 2026-08-01: nine open pull requests with zero failing
checks and zero pending checks, not one of which could merge. Six had
auto-merge ARMED, which will never fire. #74 had been in that state since
2026-07-30 and was found only because somebody went hunting for "stuck CI"
by hand.

The mechanism is that `strict = true` plus no merge queue plus a ~20-minute
suite makes merging a race: a PR is mergeable only between going green and
the next thing landing on main. Losing that race is silent. Armed auto-merge
does NOT update a BEHIND branch -- it waits on checks that already passed --
so the PR sits with no failing check, no notification, and no run in flight.

No existing signal can see it, because every existing signal is a check
OUTCOME and nothing has failed. nightly-notice.yml watches CI runs and there
is no failing run to watch; the author's last signal was a full pass. A green
dashboard and a wedged repository are indistinguishable unless something asks
"can this still merge at all?".

This does not fix the race -- only a merge queue does, filed separately as
BACKLOG #340. It converts a SILENT failure into a LOUD one, which is the part
that let #74 sit for days.

Scheduled rather than per-PR: the stall arrives when a DIFFERENT pull request
merges, so the affected PR has no run to hang a check on. Advisory by
placement and must never become required -- it reports on OTHER pull requests,
so a stall on #71 would block #128, wedging the repo with the tool meant to
unwedge it.

Verified against the live repo: 14 scanned, 8 stalled, 6 armed, exit 1. The
count differs from the hand survey's 9 because #120 was re-synced in between,
which the check correctly excluded. Tests carry a positive control (the exact
stall shape MUST be detected) alongside negative controls for failing,
pending, BLOCKED, DIRTY and closed PRs, and assert that an unclassifiable
rollup node counts as unsettled rather than green.

* ci: raise the Windows step cap, which had 1.06x margin while claiming 2x

PR #119 was killed at 26:07 against ci.yml's 26:00 `step_timeout` with ZERO
tests failing. What moved was the suite, not the code under test: #74 landed
tests/test_worktree_prune_merged.py (1,506 lines) and windows-2025 went
19:35 -> 26:07 on the same branch.

The comment beside the cap said the Windows legs were "unchanged because 26
min against the same suite is still ~2x headroom". Measured over the 11
PASSING windows-2025 runs on 2026-08-01:

  leg              max passing step   old cap   old margin
  ubuntu-latest    12:27              19:00     1.53x
  windows-2022     18:39              26:00     1.39x
  windows-2025     24:35              26:00     1.06x

windows-2025 had already PASSED at 24:35 -- 85 seconds of margin -- before
#119 died. The "2x" figure matched no leg when it was written. The same file
records this exact failure happening on the ubuntu leg on 2026-07-31 (775s
green against a 780s cap) and concludes a watchdog that cannot separate
"deadlocked" from "slow today" becomes a coin flip; ubuntu's budget was
raised then and Windows was left alone on the false claim.

Raised to step_timeout 36 (1.46x over the 24:35 max) and job_timeout 40,
preserving the nesting invariant that the step must expire strictly before
the job. Both Windows legs take the same number: windows-2022 is faster, so
sizing on windows-2025 only leaves it more room. The replacement comment
states the measured value and its date rather than a multiple -- a bare
multiple is what let this rot undetected.

Timing note recorded in the comment because it cost two sessions an error
during triage: step_timeout gates the STEP, not the job. c53f752's JOB ran
28:41 and PASSED, against job cap 30 / step cap 26.

Also files BACKLOG #340 (enable a merge queue -- 9 green PRs could not merge,
6 armed and never firing) and #344 (fixed wall-clock bounds as a class, with
this cap as instance 1 and test_stage_dispatcher.py's hardcoded 8.0s poll
budget against an injected ManualClock as instance 2).

COORDINATION: ci.yml and docs/BACKLOG.md were each held by another live
session, and the collision gate (scripts/hooks/collision_gate.ps1) refuses an
Edit while a live session's BRANCH carries a diff to the file -- it cannot
represent "coordinated, verified disjoint". Both counterparties gave explicit
written consent before these edits were applied outside the Edit tool:
zizmor-1280-adoption ("You land it. I'm standing down on ci.yml timeouts",
its only hunk being a one-line pin comment ~160 lines away) and
ha-construct-pickle-sandbox ("Go ahead with #340 now -- append after #338
exactly as you planned. I'll absorb the conflict"). No hooks were skipped;
this commit ran the full pre-commit suite.

* ci: record that #119's leg passed on a re-run at the same cap

The ADR-0154 session re-ran #119's windows-2025 leg on the SAME commit
against the SAME 26:00 cap. Attempt 1 was killed at the cap; attempt 2
concluded success. Same code, same config, same ceiling, two outcomes.

This closes the one gap in the case for raising the cap: it rules out "that
PR's tests are just slow". The leg was not failing, it was coin-flipping
against the ceiling -- which is precisely the state the ubuntu note above
already names, now demonstrated rather than argued.

It also disposes of "re-run it and see" as a diagnosis. A green re-run at
26:00 does not show the suite fits; it shows that runner was fast enough
that time. Recorded in the comment so the next person reaching for a retry
knows what a green retry does and does not prove.

Evidence contributed by the session holding #119, which is deliberately
holding its branch update until this lands so it re-rolls under the raised
cap rather than spending another coin flip at 26.

Merged main (8f01cef, #120) in the same push: #131 had gone BEHIND, which
is the stall this PR exists to report -- the fix for the cap has to survive
the cap, and the fix for silent stalls can itself stall silently.
@wshallwshall
wshallwshall merged commit 002be18 into main Aug 2, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the adr0154-sync-reply branch August 2, 2026 01:45
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…n gate crying wolf (#133)

* feat(coord): announce yourself to the other sessions in this repo

Every coordination control in this repo is PULL-based: a new session discovers
its peers from the SessionStart banner and the peers learn nothing until someone
trips the collision gate. That is too late for the collision that costs the most
-- two sessions building the same THING in different files, where nothing
file-shaped can catch it. This closes the push direction.

It ASKS, it cannot send. Hooks are shell commands and session messaging is MCP,
so the hook prints the instruction, the live peer roster and the id-resolution
rule at the first prompt that has intent to report; the model does the sending.

UserPromptSubmit, not SessionStart: at SessionStart a session knows it exists and
nothing else, so it can only say hello -- the interrupt without the information.

THE ID RULE IS THE PAYLOAD, and it is counter-intuitive enough that the text
states it with its evidence. The registry id in this repo's banners is NOT the
MCP session id; measured, a registry id and an MCP id for one session shared no
characters. Branch does not join them either -- the two rosters reported
different branches for the same checkout in 2 of 6 cases. Only cwd joins, and it
must be matched EXACTLY: every worktree cwd is an extension of the primary's, so
a prefix match resolves a peer in the primary to an arbitrary worktree session.
A registry id passed to send_message fails SILENTLY, which reads as the peer
ignoring you.

EVERY DECISION LEAVES A RECEIPT, because the bug being fixed was a hook that was
wired, fired, resolved nothing and exited 0 for weeks -- byte-identical to a
healthy hook with no peers. For the same reason the shim carries its OWN
missing-script notice: every receipt the hook writes lives INSIDE the script,
strictly downstream of the resolution failure that IS the bug, so the shim is the
one surface that still reports when the script does not resolve. It is gated on
presence.ps1 so the entry stays silent in every unrelated repo on the machine.

It always exits 0 -- a UserPromptSubmit hook that fails can block the user's
prompt. It consumes presence.ps1 and therefore the single liveness fence; it does
not invent a second notion of live. A separate 'mefor-announce' marker keeps it
outside install-coordination's mefor-coord strip and outside the website repo's
mefor-web-announce entry in the same settings file, so no installer can delete
another's hook, and -Only UserPromptSubmit -Uninstall removes announce alone
without disarming the collision gate.

* test(coord): pin the announce hook, and the anti-no-op wiring class

Most tests for a hook like this assert an ABSENCE, and a hook that does nothing
at all satisfies every one of them -- which is precisely the production failure
being fixed. So the silence assertions are paired with a positive arm: two tests
run the SAME runner against fixtures differing only in whether a peer exists, and
if the silence tests ever start passing for the wrong reason the positive one goes
red first.

test_announce_wiring.py is the class the repo had no test for AT ALL: does the
thing that gets INSTALLED reach a script that EXISTS, and does it say so when it
does not? Its absence is exactly how a wired-but-inert shim survived for weeks.
test_every_wired_script_exists_in_this_checkout was written FIRST and watched
fail, naming the missing script and printing all three paths it scanned; a green
gate is only evidence if it was shown it can see the failure.

Also pinned, each because it was got wrong somewhere first:

- The foreign UserPromptSubmit entries -- another repo's shim and an unmarked
  waiting-flag cleanup -- survive install AND uninstall byte-identical. That is
  the only thing standing between a one-line wiring edit and deleting a hook this
  repo does not own.
- A peer with no StartedAt ranks LAST, not first. ConvertFrom-Json coerces
  ISO-8601 to DateTime while the '' fallback stays String; Sort-Object over that
  mixed column raises ZERO errors and puts the empty string FIRST, so without an
  explicit projected key the least-trustworthy row silently takes the top of a
  capped target list.
- NO_SESSION_ID and DISABLED write their receipt with NO injected -StateDir. An
  earlier draft resolved the state dir after those branches, so the receipt was
  unwritable in production while a test that always injected one went green.
- Self is excluded by BOTH nets independently: a roster that cannot tell you from
  a sibling makes the session message itself.
- Hostile peer text cannot escape the peer-data block or emit a non-ASCII byte,
  a hostile session id cannot escape the state dir, and two ids that sanitise
  identically get two markers.
- Two concurrent runs announce exactly once. session-context.ps1 is registered
  twice on this box today, so double firing is a live pattern, not a hypothetical.

* docs(coord): document announcing yourself, and correct a false claim about .claude

WORKTREES.md gains the "Announcing yourself" section that the hook's own emitted
text and the shim's missing-script notice both cite by name, so the pointer has to
land on main in the same merge. It states the id rule ONCE, as the source of
record: registry id is not the MCP id, cwd is the only join key and must be
matched exactly rather than by prefix, a usable id starts with local_, and a wrong
one fails silently.

It also states what the change does NOT do. There is no receive-side hook, so the
rule that an announcement is peer DATA -- not an operator instruction, and not
something to reply to -- lives in the prose and in the fixed message shape and
nowhere else. Reachability is given honestly: presence.ps1 is authoritative for
who EXISTS, list_sessions only for who can be MESSAGED, and measured, they
disagreed 6-to-1. Cost is stated rather than left to be discovered.

CORRECTION, and it is why this doc change is in scope rather than deferred: the
same chapter claimed ".claude/settings.json is tracked (shared across worktrees)".
It is not. /.claude/ is git-ignored, and git ls-files .claude/ returns nothing --
so a worktree's copy is a creation-time snapshot nothing refreshes and several
siblings have none at all. That sentence sat at the exact point a reader decides
where to install a hook, and it argues for the wrong answer; the new section
directly contradicted it.

SESSION-DRIFT-CONTROLS.md records announce as the only PUSH control in the D4
layer, plus the two new guarantees worth tracking separately: that wiring reaches
a script that exists, and that a resolution failure is now reported by the shim.

* fix(coord): stop the collision gate blocking files a peer committed and finished

Reported by another session with a repro: it committed a file, went clean, said
in writing it was done and handed the file over -- and the peer it handed off to
was still refused the edit.

overlap.ps1's `Files` is the UNION of what a branch COMMITTED-and-not-yet-landed
with what is dirty in its tree. The gate denied on any live row in that set, so
"this branch authored it" was treated as "someone is typing in it right now".
Those are different claims. The first stays true for the branch's whole life;
only the second is what the gate exists to detect.

It self-clears on merge -- overlap already intersects three-dot with two-dot so a
LANDED branch stops claiming its files. But nothing clears it before landing, and
with PRs currently unable to merge, "until it lands" is indefinite: the blocked
set grows monotonically and is never released. Two sessions that coordinated
correctly and explicitly still cannot hand a file over. That is precisely the
failure this gate's own docstring names -- a gate that cries wolf gets
uninstalled.

overlap.ps1 already told callers to treat its signals differently ("block on
live, mention dormant"), but no caller COULD: the row unioned the two signals
away. So the row now carries `Dirty`, and the single-file query sets
`MatchedDirty` saying which signal actually matched.

The gate now DENIES only on an uncommitted edit in a live worktree, and REPORTS
committed-and-clean as context instead -- the peer may already have done what you
are about to do, which is worth knowing and not worth refusing over.

Fails SAFE across the upgrade: a cached row predating `MatchedDirty` has no such
property and is treated as dirty, so the gate degrades to its previous
over-blocking rather than silently permitting a real collision.

Also, while in the file: `git status` now runs with --no-optional-locks. A plain
status REWRITES the index of the repo it inspects, and this walks every peer
worktree -- so merely asking "what is in flight" was mutating other sessions'
checkouts.

Verified against the live repro and both directions: the reported file now
allows with context; a file with uncommitted changes in a live worktree still
denies; an untouched file stays silent.

* feat(coord): lead the announce roster with the claim note, not the worktree name

Reported by the session it happened to: its worktree is named
inter-session-communication-*, auto-generated at creation from a task that
session has never worked on -- it has been doing ASVS scorecard work for its
entire life. The directory name is the most visible identifier in presence.ps1,
overlap.ps1 and this hook's output, and it had already misled TWO sessions
(including this one) into guessing that session was building the announce hook.

A worktree name is a creation-time label, not a statement of current work, and
nothing keeps the two in sync. The claim note is the only field written
DELIBERATELY to say what a session is doing, so the roster now prints it, and the
legend tells the reader to prefer it over the name.

Joined on the claim's `worktree` path, normalised the same way as every other cwd
key here. Fail-open throughout: no claims directory, an unreadable claim, or a
peer with no claim all just mean the name is the only thing we have -- which is
exactly the status quo, never an error.

Same session also flagged that the branch I read for it from list_sessions was
stale (a spent, merged branch). The announce text already refuses to join on
branch and says why; this is a second, independent reason not to trust it.

* docs(coord): name the silent-control defect class in the drift inventory

A control that cannot distinguish 'ran and resolved' from 'ran and found nothing'
is not installed, however it looks. The announce shim outlived every other
silent-control defect found the same day BECAUSE it printed a status message --
which is more convincing than silence.

The structural cause is the reusable part: every receipt that hook would have
written lived inside the script the shim failed to find, so every check sat
strictly downstream of the failure it existed to detect. Looking was not
neglected, it was impossible. The question to ask of a new control is which
surface still reports when the control itself fails to load.

Formulation owed to a peer session that hit four instances of this class in one
day and named it more sharply than I had.

* docs(coord): record the broadcast constraints six sessions learned the hard way

Announce-on-join introduces a session; it does not let an established one push an
operational notice. That increment is deferred, and on 2026-08-01 six sessions
rehearsed it by hand for four hours. Three constraints fell out, recorded so the
next attempt does not rediscover them:

- A broadcast needs an EXPIRY or a predicate the RECIPIENT can evaluate, never a
  promise from the sender. A merge freeze shipped with 'lift when #119 merges';
  #119 died on an unrelated CI timeout, so five sessions held on a condition that
  could not arrive and a second round was needed to retract it.
- 'Don't do X' is the wrong primitive when automation already has X armed. The
  freeze asked for restraint while six PRs had auto-merge ARMED and would have
  landed with nobody clicking anything. The right ask was an action: disarm.
- Coordination a tool cannot read does not count. Two sessions agreed IN WRITING
  to hand over a file and the gate still refused, because the agreement was prose
  and the gate reads git.

Field data from the sessions that lived it, not speculation.

* test(coord): pin overlap's dirty-vs-committed signals against real git

Nothing drove overlap.ps1's row computation against a real repository, so the
question "does MatchedDirty hold when a file is dirty AND committed at once" was
unanswerable by the suite. Raised by the session that spent an evening in exactly
that state.

THAT CASE IS THE ONE THAT FAILS SILENT, which is why it gets a real fixture
rather than a stub row. A peer with uncommitted edits in one region and landed
work in another is a genuine collision. Had MatchedDirty been derived from the
committed diff instead of the working tree it would read FALSE there, the gate
would allow, and two sessions would write one file with nothing reported. The
over-block this replaced was loud and annoying; that would be quiet and cost
someone their work.

Verified the tests can SEE it rather than assuming: sabotaged the row to publish
an empty Dirty set -- the precise mis-implementation warned about -- and both
MatchedDirty assertions went red; restored, all five green. A test written after
the code, never observed failing, is a test of nothing.

Also pins that overlap does not rewrite a peer worktree's git index, by comparing
the index mtime across two queries. An observer must not perturb what it
observes, and this one was doing so on every PreToolUse before f55d6c6.

Stub rows would only have asserted that the plumbing carries a value someone else
computed; the whole question here is what git actually reports.

* test(coord): assert a wired coordination hook resolves to a script that exists

Raised by the session that traced the shim: the coordination hooks are not
installed copies, they are inline commands that locate their script in a working
tree at every invocation. If neither base yields the file, Test-Path fails, the
loop ends, nothing runs, and the tool call proceeds with no hook and no signal.
"The hook is uninstalled" and "the hook ran and permitted this" are
indistinguishable from outside, and nothing was watching.

Not hypothetical: a foreign UserPromptSubmit entry sat in this same settings file
for weeks probing a script that exists only in another repo.

The risk composes badly for collision_gate.ps1 specifically, which now (a) fails
OPEN on any error, (b) denies less by design after the dirty-vs-committed split,
and (c) silently no-ops when unresolvable. Individually defensible; together the
realistic bad day is "the gate was never running and nobody noticed". This closes
(c) -- the observation is not mine, and it is a good one.

Found immediately on writing it: FIVE user settings files across account
directories, not the one I knew about. The informational test also prints the
original defect as output rather than leaving it invisible:
  FOREIGN UserPromptSubmit [mefor-web-announce] -> scripts/hooks/announce.ps1:
  RESOLVES NOTHING HERE
It is another repo's entry, so this reports it and does not touch it.

Carries a NEGATIVE CONTROL, because the assertion passed on the first run and a
green that has never been shown to fail is not evidence. The real hooks cannot be
unwired to prove the predicate works -- the primary checkout is shared with live
sessions -- so it is exercised against a path known not to exist.

Local-machine only: CI has no user settings and these skip there, which means CI
does NOT guard this property. Said plainly, and every test prints what it scanned
BEFORE it can skip, per test_gate_installed_parity.py -- the pytest config has no
-rs, so a skip would otherwise render as a bare dot with no reason.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ure was deferred after it shipped (#137)

* docs: intake auth shipped in increment A, but three docs still called it deferred

ADR 0154 increment A landed this morning (f2ef0ea, PR #109) and shipped
intake_auth on the inbound HTTP listen socket. PR #110 corrected the ADR's own
status line; nothing corrected the docs the ADR itself names at line 165 as
"docs to update on build".

CONNECTIONS.md contradicted itself as a result. Line 534 documents intake_auth
as a shipped setting with its full schema, while line 569 thirty lines below
said request authentication "is shaped but not shipped -- so an exposed
listener belongs behind the reverse proxy that terminates auth". A reader
following the second sentence stands up a reverse proxy to buy a control the
connector already has.

Fixed at the three sites where increment A is now false, and left the
synchronous-reply deferral alone -- that one is still true until #119 lands:

- CONNECTIONS.md dagger paragraph (REST-IN/SOAP-IN) -- the ADR names this
  paragraph specifically as the one the build retires.
- CONNECTIONS.md "Not built (first slice)" -- the false sentence is DELETED
  rather than reworded. The settings table above it already owns this fact, and
  restating status in a second place is what produced the contradiction.
- CONNECTIONS.md competitor-parity table, REST row.
- FEATURE-MAP.md capability catalog -- public-facing, so a stale "deferred"
  here understates shipped capability to anyone reading the mirror.

docs/BACKLOG.md carries the same staleness in two more places (the #7 summary
row and item #7's banner). It is deliberately NOT in this commit: two live
sessions are appending to that file right now and collision_gate.ps1 blocked
the edit. Coordinated with both; it follows once they land.

Verified: test_feature_map_claims, test_backlog_status_check, and the four
other suites that read these two files -- 238 passed, 3 skipped.

* docs: retire increment B's deferral, and document reply_from where an operator looks

ADR 0154 increment B (PR #119) makes the inbound HTTP listener a proxy: naming
reply_from blocks the HTTP turn until the named outbound's reply is captured and
COMMITTED, then returns it. Six sites still described that as unbuilt, and one
shipped setting group was documented nowhere outside the ADR.

DO NOT MERGE THIS BEFORE #119. Every claim here is true only once increment B is
on main.

The ADR's own status block (lines 3, 12-18) said "Increment B remains
unauthorised" and "Building increment B requires a further owner decision" --
the same defect 5dab6a0 fixed for increment A. Corrected, keeping the rev-4
split as the decision record rather than deleting it, since an ADR is a record of
what was decided and when.

Two sites were worse than stale. The fixed-JSON 502 on a partner rejection was
justified BY the deferral -- "with no committed customer this is a documented
limitation rather than a blocker ... and increment B is deferred until one
exists". Increment B shipped without capture_error_responses, so that premise is
gone while the gap remains. Per CLAUDE.md section 11, a compensating control must
not rest on a false premise: both sites now say it is a live limitation of
shipped code, accepted explicitly by the owner, rather than a consequence of
deferral.

CONNECTIONS.md had no reply_from at all -- six shipped settings with no operator
documentation, while its increment-A sibling intake_auth was fully documented.
Added the settings rows, what the mode does and why the committed row is the sole
authority, and the check-time refusals (FIFO ordering and finite max_attempts on
the named outbound, both of which would queue N callers behind one lane). The
capture_error_responses gap is stated here too, because "correct only when the
partner succeeds" is something you need before you point a partner at it, not
something to find in an ADR.

FEATURE-MAP.md bundled the SOAP-IN reply with the FHIR-IN facade under one
deferred mark. Split: the reply ships, the facade does not. The file's legend has
no partial mark, so one row could not honestly carry both.

Also applied section 11's "state a load-bearing fact ONCE" to the dagger
paragraph, which now points at the Http() section instead of keeping a second
copy of that status -- duplication is what let these drift apart twice.

NOT included, deliberately:
- docs/BACKLOG.md carries the same staleness in two places (#7 summary row, item
  #7 banner). Two live sessions are appending to that file; collision_gate.ps1
  blocked the edit and I coordinated with both rather than overriding it.
- messagefoundry/config/wiring.py ships a self-contradicting Http() docstring in
  #119 itself: "The synchronous downstream-reply (SOAP-envelope) path is a
  defined ADR 0013 follow-on, not built here" sits ~30 lines above the new
  "Synchronous captured-downstream reply" section documenting it. Not fixable
  from this branch -- that code is not on main yet.

Verified: 124 doc-gate tests pass; a markdown table-structure check (which caught
a separator I dropped in the ADR, and was proven against a deliberately broken
copy first) reports 0 mismatches across all three files.

* backlog: item #7's deferred tail still listed two things that shipped today

BACKLOG #7 is the ledger entry ADR 0154 was written against, and ADR 0154's own
item 13 flags it as a doc the build must retire. It named intake-auth and the
SOAP sync-reply as deferred in two places -- the summary row (401) and item #7's
banner (884). Increment A shipped intake auth this morning (f2ef0ea); increment
B ships the sync reply in PR #119.

Both sites corrected, and the banner now also carries the capture_error_responses
gap, since "the SOAP reply shipped" without "a partner 4xx still returns a
fixed-JSON 502" is the half-truth that would let someone plan a feed around it.

DO NOT MERGE BEFORE #119 -- the sync-reply half of this is true only once
increment B is on main.

ON OVERRIDING collision_gate.ps1, deliberately and with the reasoning recorded:

The gate blocked this edit because two live sessions had docs/BACKLOG.md in their
branch diff. It is file-granular and cannot compare hunks. Before overriding I
established, and did not assume:

- Both blocking sessions are PURE EOF APPENDS at line 8242 with zero deletions
  (verified from their worktrees: @@ -8242,3 +8242,78 @@ and @@ -8242,3 +8242,69 @@).
- This commit touches lines 401 and 884 -- roughly 7,400 lines away. Confirmed
  after the fact: git diff -U0 reports exactly those two hunks.
- BOTH sessions gave explicit written clearance, unprompted, and one confirmed it
  will not touch item #7 at all.
- The gate's own remedy is "coordinate first"; that was done first, not after.

The gate cannot re-evaluate any of this, and its predicate keys on a live
session's branch diff -- so a session whose work is committed and final blocks
this file for its entire remaining lifetime. Waiting would not have cleared it.

Its docstring says it "must never be the reason a session cannot work" and that
"a gate that cries wolf gets uninstalled". This is the third false denial today
on provably disjoint hunks (the HA re-check session hit it on wiring_runner.py
with ~1700 lines of separation). A hunk-offset proposal has gone to the session
owning the coordination hooks, because the durable fix is to compare ranges
rather than filenames -- the Bash escape used here is trivially available to
anyone, which is precisely why the gate needs to be right rather than loud.

Verified: test_backlog_status_check 15 passed (the banner invariant, which this
edits inside of), table structure 0 mismatches, CRLF preserved byte-for-byte.

* docs(adr): ADR 0023's Status line still called its deferred tail deferred

A repo-wide sweep for the same defect turned up a seventh site, and it is the
parent ADR of the work itself:

  Status: Accepted (2026-06-27, built - first slice in 0.2.10;
          SOAP-reply/auth/routing-metadata deferred)

Two of those three shipped. Intake auth landed this morning as ADR 0154
increment A (f2ef0ea); the SOAP-envelope synchronous reply is increment B
(PR #119). Only routing-metadata is still genuinely deferred.

This is the same defect PR #110 fixed on ADR 0154's own status line, in the
document one level up that nobody looked at. ADR 0023's BODY was already fine -
it links forward to ADR 0154 at lines 319 and 327 - so a reader who got that far
was told the truth. Only the header, which is what most readers actually read,
was wrong.

The replacement links to ADR 0154 as "the authority on their current state"
rather than restating what shipped, because restating the build state in a
seventh place is what produced the first six.

DO NOT MERGE BEFORE #119 - the SOAP-reply half is true only once increment B is
on main. The intake-auth half is already true on main today.

Deliberately NOT changed, having checked both:

- CHANGELOG.md:658 says the sync-reply and intake auth "are deferred follow-ons".
  It sits under "## [0.2.10] - 2026-06-27" / "### Added", where that was true.
  A changelog records what a release contained; editing it would falsify the
  release record. The new capability belongs in the entry for the release that
  ships it, which is a release-time task, not this one.
- ADR 0023's Decision paragraph (line 93) still says the synchronous reply "is a
  defined follow-on". That is a true record of what ADR 0023 DECIDED, and an ADR
  body is a decision record, not a status board. The header now carries the
  current state, which is the right division.

Verified: link target resolves, 24 doc-gate tests pass.

* fix(http): the listener's module docstring denied the feature the module implements

ADR 0154 increment B merged as 002be18 and made this module a proxy: naming
reply_from blocks the HTTP turn until the named outbound's reply is captured and
committed, then returns it as the body. The module docstring at the top of the
file it landed in still said:

  **First slice (ADR 0023 D3).** Only the cheap, correct 202-respond-with-receipt
  path is built. A synchronous downstream-reply (the SOAP-envelope
  block-on-captured-downstream-reply seam) is a defined ADR 0013 follow-on and is
  **not** built here.

"Not built here" in the docstring of the file that builds it. Anyone reading
http_listener.py top-down is told the feature is absent before reaching the code
that implements it.

Replaced with the two response modes and a pointer to Http() for the settings and
their check-time refusals, rather than a second copy of that surface here. That
is the same rule the rest of this cleanup applies (CLAUDE.md section 11): the
status was restated in a place that could not be maintained, which is why it
drifted the moment the feature shipped.

The sibling site is Http()'s own docstring in config/wiring.py, which carries the
identical claim ~30 lines above its own "Synchronous captured-downstream reply"
section. It is NOT in this commit: collision_gate.ps1 reports wiring.py held by
the live session landing PR #132, whose changes are confined to the Email() and
Direct() factories ~646 lines away. I have their written clearance and verified
the separation, but they merge next by agreement, so waiting means my branch is
based on theirs and the conflict does not need resolving at all.

Verified: ruff format --check clean, ruff check clean, module parses.

* fix(config): Http()'s docstring denied the reply path documented 30 lines below it

The sibling of f95ddb2. Http()'s docstring described respond-with-receipt and
closed with:

  The synchronous downstream-reply (SOAP-envelope) path is a defined ADR 0013
  follow-on, not built here.

Roughly thirty lines below, in the same docstring, sits the section
"Synchronous captured-downstream reply (ADR 0154 D4)" describing the feature in
detail. Both shipped in 002be18. A reader of the API reference for the factory
that CONFIGURES the reply path was told it does not exist, then shown how to
configure it.

Rewritten so the 202 paragraph says what it actually is -- the behaviour of an
inbound WITHOUT reply_from -- and hands off to the section below rather than
denying it. No second copy of that surface, per CLAUDE.md section 11.

That completes the sweep: eight sites across five files (ADR 0154 x6, ADR 0023,
BACKLOG #7 x2, CONNECTIONS.md x5, FEATURE-MAP.md, and these two docstrings), all
the same defect -- a build-status restated where nothing linked it back to the
thing that changed underneath it.

ON OVERRIDING collision_gate.ps1 A SECOND TIME, with the reasoning recorded:

The gate blocked this because the live session landing PR #132 has wiring.py in
its branch diff. Established before overriding:

- Their tree is CLEAN -- the change is committed and pushed, not in progress.
- Their hunks are at 1785-1890 (the Email() and Direct() factories); this edit is
  at ~1132 (Http()). ~650 lines apart, verified by reading both diffs.
- They gave written clearance twice, unprompted, and explicitly offered to "drop
  out of the way entirely if it drags".
- #132 is currently blocked on an unrelated SQL Server container failure
  (pyodbc HYT00 on the MERGE cipher_meta concurrent upsert; the 2025 leg passes
  the same commit), so waiting had no bounded end.

The decisive point is narrower than any of those: committed-with-a-clean-tree is
EXACTLY the case f55d6c6 fixes. That commit narrows the deny predicate to
UNCOMMITTED edits, and its author verified the fixed gate ALLOWS this scenario
against this very file. So the denial here is a known defect with a written,
tested, merge-pending fix -- not a judgment the gate is entitled to make. It
cannot reach us yet only because the gate resolves from the primary checkout,
which needs the fix merged AND the primary advanced.

Verified: ruff format --check clean, ruff check clean, mypy --strict 260 files.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…om maxima

The correction in this PR fixed #131's maxima but repeated #131's own failure
mode in a new place: it reported a pool and an n that its stated pool cannot
produce. Two independent re-derivations agree the day holds 70 ci.yml runs, not
101, and per-leg n = 42 / 39 / 36, not 57 / 52 / 49. The three MAXIMA
(12:31 / 21:34 / 25:51) re-derive to the second and are unchanged, so the
36:00 step / 46:00 job decision stands on the same numbers it always did.

What actually changed:

* POOL AND n. 70 runs created 2026-08-01 UTC, cross-checked four ways;
  n = 42 / 39 / 36. A table whose own point is "state your pool and your n"
  has to carry an n the next reader can recompute.

* THE POOL IS RIGHT-CENSORED, and nothing said so. Every run in it predates
  #131 (28d186b, 2026-08-02T00:35:28Z), so each observation survived a 26:00
  cap. 25:51 is the largest step that FIT, not the largest the suite wants, and
  a multiple of a censored maximum under-provisions by construction. This is
  why 1.06x read as survivable right up to the moment #119 died.

* THE JOB-CAP ADDENDS WERE TYPICAL VALUES, NOT MAXIMA. 0:41 is the median
  setup; the web-console figures 1:58 / 3:27 are each only third-highest on
  their leg. Measured maxima are 2:00 / 2:33 / 3:33 (web) and 1:20 / 1:09 /
  1:04 (setup). Re-summed, ubuntu was -0:20 and Windows -0:37 against the old
  caps: BOTH were already negative, not just Windows.

* THE NESTING INVARIANT IS NOT RESTORED, and now says so. It holds for
  `Tests (pytest)` on every leg and for `Web console tests (pytest)` on none:
  reaching that step already spends setup plus `Tests`, so its own cap can
  never fire first. A hang there still surfaces as an unattributed job kill.
  Stating otherwise would rest a compensating control on a false premise.

* The "5:26 spread ... identical code" claim is withdrawn; neither endpoint
  re-derives. The true in-pool spread is 9:55, which is wider and supports the
  decision more strongly than the figure it replaces.

Also corrects three pre-existing claims in this file that match no
configuration -- the same defect class this note is about:
  - the pytest step's cap described as `timeout-minutes: 13` (it is
    matrix.step_timeout, 19 or 36)
  - "the 15-min job cap" (it is matrix.job_timeout, 26 or 46)
  - the mechanical margin guard cited as BACKLOG #341, which does not exist on
    main; it is #344 item 1

Drops HANDOFF-ci-margin-correction.md: session ephemera, and no HANDOFF file
has ever been tracked at the repo root on main.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
#344 is the item about bounds stated independently of the work they bound. Its
instance 1 still carried the retracted measurement -- 24:35 over "11 runs",
1.06x, raised to "1.46x" -- every figure of which was superseded, and the edit
had been outstanding since it was filed.

Instance 1 now reads 25:51 / 1.006x / 1.393x, and carries its pool (70 ci.yml
runs created 2026-08-01 UTC; per-leg n = 42 / 39 / 36) so the next reader can
recompute it rather than trust it.

Adds two things the re-measurement turned up:

* INSTANCE 3 -- the CI job cap. Two steps in that job carry step_timeout, so
  job_timeout must cover their sum, and the +4 convention that sized it was
  never summed against what it had to hold. Recomputed from measured maxima,
  ubuntu was -0:20 and windows-2025 -0:37: both already underwater. It presents
  as a GREEN first step followed by an unattributed job-level kill, which is a
  signature instance 1's own proposed margin check would not catch, because the
  step it measures passed.

* THE CENSORING TRAP. A max-passing figure is a LOWER BOUND: the pool is
  censored by whatever cap was in force when it was collected, so the runs that
  would have exceeded it were killed and are missing from exactly the tail being
  measured. This is why 1.006x looked survivable until #119 died.

Proposal 5 records the structural fix instance 3 does not make: give the
web-console step its own cap, because until then ci.yml's nesting invariant is
unenforceable for the second gated step on every leg.

Banner moves from "not started" to PARTIAL -- instances 1 and 3 are fixed,
instance 2 is not. One banner, still OPEN.

Also records, once, that instance 1's figures have now been published wrong
twice; the maxima survived both passes and the pools did not.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
Reported by the announce-hook session, verified here: gh pr view 119 gives
MERGED, mergedAt 2026-08-02T01:45:00Z. What the 26:00 cap killed was a RUN, not
the PR.

"#119 died" reads as never-landed, and that reading had already propagated --
docs/WORKTREES.md asserted #119 "never merged (it died on an unrelated CI
timeout)", which that session corrected against the API. This block was one of
the places the phrasing came from, so it is fixed at the source rather than
only downstream.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ments in the item itself

The #340 filing was agreed to need cost evidence. The hand account it was to be
built from ("#132 took four full CI cycles to land, none from a failure — green ->
main moved -> rebase, x3, ~80 min of runner time for a change correct on the first
pass") was wrong in every load-bearing figure, so this re-derives all of it from the
Actions API instead of relaying it.

What #132 actually did: nine CI runs / ten attempts across eleven head moves in a
3h28m window; two attempts failed on real defects (one on the head it opened at, so
it was not correct on the first pass — it opened at one commit and merged at five);
four runs cancelled in flight by cancel-in-progress; five head moves were rebases,
four of them onto a main tip that had landed 1-14 min earlier. Cost 212.7 min run
wall-clock / 442.5 min job wall-clock — quantities that differ by 2.08x, so the unit
is load-bearing. No billable figure exists: /timing reports 0 billable ms (self-hosted).

Two statements already in the item were false and are corrected:

  - "A hand-coordinated merge freeze ... *did* hold main still for a full window."
    It did not. main advanced four times between #119's first fully-green head and
    its merge, one of them 8m26s after the freeze was recorded in a work claim.
  - "#119 still failed" / "the bounds that actually killed #119" reads as the PR
    dying. #119 merged at 2026-08-02T01:45:00Z, 12h15m after arming.

The caveat is stated level with the cost, per the standard this repo keeps
violating. Notably the overnight serialization is NOT evidence hand coordination
worked — it is entailed by strict + a 20-25 min suite — and strict does not always
serialize (#110/#111 merged 24s apart; mechanism not established). Also measured:
no workflow carries a merge_group: trigger, so zero of the 13 required contexts
would report on a queue ref, which makes Proposed step 2 a precondition rather than
a follow-up.

Adds the measurement-cost linkage to #344: supersession-and-re-run produces
multi-attempt runs, and the default actions/runs/{id}/jobs endpoint returns only the
latest attempt, so failed earlier attempts vanish from any duration table built from
it — at exactly the tight end where a margin is decided.

#344's own figures are untouched; another session holds that item.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
… reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…heir sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ments in the item itself (#143)

* backlog(#340): the merge-queue cost, re-derived — and two false statements in the item itself

The #340 filing was agreed to need cost evidence. The hand account it was to be
built from ("#132 took four full CI cycles to land, none from a failure — green ->
main moved -> rebase, x3, ~80 min of runner time for a change correct on the first
pass") was wrong in every load-bearing figure, so this re-derives all of it from the
Actions API instead of relaying it.

What #132 actually did: nine CI runs / ten attempts across eleven head moves in a
3h28m window; two attempts failed on real defects (one on the head it opened at, so
it was not correct on the first pass — it opened at one commit and merged at five);
four runs cancelled in flight by cancel-in-progress; five head moves were rebases,
four of them onto a main tip that had landed 1-14 min earlier. Cost 212.7 min run
wall-clock / 442.5 min job wall-clock — quantities that differ by 2.08x, so the unit
is load-bearing. No billable figure exists: /timing reports 0 billable ms (self-hosted).

Two statements already in the item were false and are corrected:

  - "A hand-coordinated merge freeze ... *did* hold main still for a full window."
    It did not. main advanced four times between #119's first fully-green head and
    its merge, one of them 8m26s after the freeze was recorded in a work claim.
  - "#119 still failed" / "the bounds that actually killed #119" reads as the PR
    dying. #119 merged at 2026-08-02T01:45:00Z, 12h15m after arming.

The caveat is stated level with the cost, per the standard this repo keeps
violating. Notably the overnight serialization is NOT evidence hand coordination
worked — it is entailed by strict + a 20-25 min suite — and strict does not always
serialize (#110/#111 merged 24s apart; mechanism not established). Also measured:
no workflow carries a merge_group: trigger, so zero of the 13 required contexts
would report on a queue ref, which makes Proposed step 2 a precondition rather than
a follow-up.

Adds the measurement-cost linkage to #344: supersession-and-re-run produces
multi-attempt runs, and the default actions/runs/{id}/jobs endpoint returns only the
latest attempt, so failed earlier attempts vanish from any duration table built from
it — at exactly the tight end where a margin is decided.

#344's own figures are untouched; another session holds that item.

* backlog(#340): the protocol cost, and a measurement claim of mine that was imprecise

Two amendments from peer review, both of which improve on what I wrote.

1. My measurement-cost paragraph conflated two different prunings. Corrected by
   the ci-margin-correction session, who hit the same trap from the other side and
   re-measured with ?filter=all. The precise statement:

     - filtering on JOB conclusion deletes job-cancelled/step-succeeded rows, which
       are the tightest by construction;
     - the default latest-attempt view hides FAILED earlier attempts. It does NOT
       move a step-success maximum -- so my implication that it changes the margin
       was wrong. What it hides is that the sample is RIGHT-CENSORED: the largest
       observable step is the largest that FIT under the cap.

   Keying on the step's own conclusion (not the job's) and reading ?filter=all are
   two separate fixes for two separate defects. Still no #344 figure is quoted here.

2. Adds the protocol cost, relayed independently by two sessions and assembled by
   sandbox-codec. It is the strongest argument in the item and is not a throughput
   argument: with no queue, sessions invent an ordering ritual, and the ritual is
   less reliable than the mechanism it replaces. The self-reported instance -- a
   session promising not to jump the queue while its own PR had auto-merge armed --
   is already named as a failure mode in WORKTREES.md, which that session had read
   about this very freeze hours earlier.

Also: re-read the open-PR set nine hours after the first measurement. 15 open, 10
armed, still 0 CLEAN. Membership churns; the condition has never lifted.

Removes a "20-25 min" suite duration I restated twice without deriving it -- the
What section already states it once, which is where it belongs.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
#140)

* fix(coord): overlap gave two different answers the same bytes, twice

Two defects in one script, and they are the same defect: a signal that
cannot distinguish the state it reports from a different state.

1. A -Json query answered "nobody else is in this file" by printing
   NOTHING. `@() | ConvertTo-Json -AsArray` sends zero objects down the
   pipeline, so ConvertTo-Json never runs -- -AsArray only shapes output
   that already exists. On stdout an all-clear was therefore byte-for-byte
   identical to the script dying before it answered, and no consumer could
   tell them apart. Every -Json exit now goes through one emitter that
   always produces an array. (-InputObject is not the fix: with -AsArray it
   double-wraps to [[]].)

   Found by running the real script against the real collision gate rather
   than the test stubs, which had been written to a shape the real script
   never produced.

2. A live session was attributed to a worktree by FIRST prefix hit. Linked
   worktrees live under the primary checkout, so every linked path is also
   a prefix match for the primary's row: the primary was handed whichever
   nested session the hash table enumerated first, and reported LIVE on
   main, "building" a peer's task list. Hash order is not stable, so it was
   a different wrong answer each run -- which is why it read as noise
   rather than as a bug. Longest prefix wins is the only rule that survives
   nesting, and it is resolved once against every worktree instead of per
   row.

   docs/WORKTREES.md already named this exact trap for the announce hook's
   id rule, where the cure was "never match by prefix". Here a prefix match
   is genuinely required -- a session may sit in any subdirectory -- so the
   cure has to be longest-prefix instead.

Both are pinned against a real nested-worktree git fixture; a sibling
layout would pass under the old rule and prove nothing. Each new assertion
was checked against the unfixed script first: the attribution test reports
the primary as Live/main/<peer session id>, and the array test sees ''.

* fix(coord): the collision gate reported an all-clear when it had checked nothing

Every fail-open path in this hook -- overlap script missing, throwing, or
printing garbage -- exited 0 with EMPTY STDOUT. On a PreToolUse hook whose
stdout is parsed as a decision, empty stdout means "allow", which is
byte-for-byte what "checked, nobody else is in this file" looks like. So a
gate that had consulted nothing was indistinguishable from a gate reporting
all-clear, and its own failure reached the session as reassurance.

That is the silent-control class this repo has now hit five times, and it
is the same shape as the wired-but-inert announce shim: the surface that
was supposed to report sat downstream of the failure it existed to detect.

The posture does not change -- every one of these paths still ALLOWS. Only
the silence does. It now emits a hookSpecificOutput.additionalContext
notice naming which reason (overlap-missing / overlap-failed /
overlap-empty / overlap-unparseable / payload-unreadable). It must be that
JSON shape and never a bare line: this hook's stdout is a decision, so a
stray line risks a misparse on every Edit and Write -- a diagnostic that
would be a worse fault than the one it reports. There is deliberately no
permissionDecision key: a notice that blocked would invert the fail-open
posture that is the whole point of this gate.

Rate-limited per reason (30 min, -NoticeCooldownMinutes) so a persistently
broken overlap cannot narrate itself into every edit -- this gate's own
docstring records where a gate that cries wolf ends up. The stamp lives
under -StateDir, defaulting to the repo's coordination dir and resolved
ONLY when about to report, so nothing new runs on the hot path. If the
stamp cannot be read or written the notice is emitted anyway: the failure
mode of a noise-suppressor must be noise, never quiet, or an unwritable
directory silently restores exactly the behaviour this removes.

Distinguishing overlap-empty from a resolved "nobody" required fixing the
producer first (previous commit) -- you cannot detect a difference the
producer never encoded. Verified against the real overlap script, not only
the stubs: an ordinary edit to an untouched file is silent.

Tests: -StateDir isolates the throttle per test, or the first notice would
silence the next test's and the suite would pass on run order.

* fix(coord): claim.ps1 accepted a new note, reported success, and discarded it

-Take documented itself as idempotent -- "re-taking your own claim just
refreshes the note" -- and did not refresh anything. A new -Note was taken,
acknowledged and dropped.

That is worse than an outright failure, because of what the note is for.
It is the only field written deliberately to say what a session is doing,
and announce-session.ps1 broadcasts it to every session joining the repo
while telling them to prefer it over the worktree name. So the one field
elevated to authoritative was the one field that could not be corrected.
Measured 2026-08-02: a claim note was still announcing "NO PR OPENED --
honouring the #119 merge freeze" to every joining session hours after both
that PR and the one it gated had merged.

The workaround people reached for -- -Release then -Take -- drops the claim
in between, re-opening the race the claim exists to close.

Re-taking a key you hold now rewrites the file in place: note, branch (a
worktree can have switched branches, and a claim naming a branch nobody is
on is another confidently-wrong coordination fact) and a new `refreshed`
stamp, leaving `claimed` untouched -- which is what proves the claim was
never let go. Write-then-rename, not a truncating write: claim_check.py
swallows a JSON parse error into "not claimed", so a torn file is a
silently disabled gate, and a crash mid-refresh must leave the old note.
Mutual exclusion is unchanged and pinned: a peer's key is still refused.

One trap found by the test rather than by reading. ConvertFrom-Json
silently coerces an ISO-8601 string to [datetime], so [string]$c.claimed
returns the local short form -- sub-second precision and UTC offset gone.
Writing that back would have downgraded the stamp on every refresh, and it
would still have parsed, so nothing would ever have complained. Stamps now
round-trip through "o", and the test asserts byte equality rather than
"still parses". The same coercion is handled where announce reads it, with
an invariant-culture parse for the string case.

announce-session.ps1 now prints each claim note's AGE (from `refreshed`
else `claimed`, "age unknown" when it cannot be determined -- an unknown
age must not render as a fresh one). Elevating a note to authoritative
makes a stale one strictly more dangerous than none, and age is the cheap
signal that lets a reader discount it.

Not taken here: claim -List's staleness-vs-liveness rendering, which is
already open as its own change.

* docs(coord): record the three fixes, and correct a claim that has expired

SESSION-DRIFT-CONTROLS.md: a fifth instance of the silent-control class,
in the collision gate itself, added to the callout that names the class.
It carries the part worth reusing -- the fix was not "check harder", it was
giving two states different bytes, and the first attempt failed because the
PRODUCER had never encoded the difference. Status-table rows for the three
controls, and the claim-refresh behaviour beside claim.ps1's entry.

WORKTREES.md: the announce id rule already warned that a prefix match
resolves a peer in the primary to an arbitrary worktree session, because
every worktree cwd extends the primary's. overlap.ps1 had that same trap
live at the same time. Noted there, with the distinction that matters:
overlap genuinely needs a prefix match, so the cure is longest-prefix
rather than exact-match.

And a correction. The broadcast-constraints list said of last week's merge
freeze that "#119 never merged (it died on an unrelated CI timeout)". It
merged the following day, 2026-08-02 01:45Z. Verified against the API
rather than restated. The lesson is unchanged and in fact sharper: the
recipients could not evaluate the predicate, so the freeze outlived its own
condition in both directions -- five sessions held while it had not
arrived, and a claim note was still announcing it hours after it had.

* docs(coord): announce-on-join merged and was never installed

Found while checking a peer session's report, not by looking for it. That
session announced itself by hand on 2026-08-02 and gave the reason as "the
hook is on an unmerged branch". It had merged (#133, 3389aa2) hours
earlier, so the observation was right and the diagnosis was not, and
nothing would have corrected it.

Measured across all five config roots:

  - no `mefor-announce` UserPromptSubmit entry anywhere
  - the one UserPromptSubmit entry installed is `# mefor-web-announce`,
    which resolves scripts/hooks/announce.ps1 -- a different script in a
    different repo, and one the installer's own comment already warns is
    easy to confuse with this marker
  - <git-common-dir>/mefor-coord/announce/ does not exist, so there is not
    a single receipt: it has never executed

install-coordination.ps1 was last run before the announce row existed, and
merging a hook does not install one. Its two other entries -- the
SessionStart banner and the collision gate -- were wired then and are
present, which is precisely why nothing looked wrong.

The part worth carrying: the missing-script notice was built so this class
could not hide, and it CANNOT FIRE when the hook is not wired at all,
because it lives inside the shim. Same shape as the defect this document
already records one level down -- the detector sat downstream of the
failure it existed to detect. So the status table now distinguishes rule
4's inert-BY-DESIGN from this one's inert-BY-ACCIDENT, and the confirmation
step is a receipt on disk rather than a reading of the settings file.

Not installed here: that writes ~/.claude/settings.json, which is shared
with every session on this machine. Owner's call, from a plain terminal.

* fix(coord): five defects this PR's own first pass introduced or left

Found by an adversarial review of the preceding commits, then each one
reproduced by execution before being touched. Two were regressions I had
introduced; three were gaps.

1. THE CLAIM FILE'S EXISTENCE IS THE LOCK, and the refresh unlinked it.
   `Move-Item -Force` is delete-then-rename. The take path is an exclusive
   CreateNew, so any instant the name does not exist is an instant another
   worktree can claim a key we hold -- i.e. the note refresh could hand a
   claim away. Measured on this box: 400 moves left the destination absent
   on 2,559 of 154,506 polls. [IO.File]::Move with overwrite is
   MoveFileEx(MOVEFILE_REPLACE_EXISTING), and the same harness never once
   saw the name missing across 134,581 polls. It fails transiently instead
   (13.5% under back-to-back churn, nothing like one refresh per run), so
   it retries five times and then reports; failing is the safe direction --
   the old note survives and the claim stays ours.

   The catch around it is deliberately UNTYPED: PowerShell wraps a .NET
   method's exception in a MethodInvocationException, so the typed catch I
   wrote first never matched, the failure escaped to ErrorActionPreference
   = Stop, and the temp file was orphaned in the claim registry. The
   orphaned-temp assertion is what caught it.

2. `overlap.ps1 -Json` emitted `[null]` for an empty map. Build-Map returns
   AutomationNull, which PARAMETER BINDING converts to a real $null at the
   call -- and `@($null).Count` is 1, so the zero-rows guard was dead in
   exactly the case it was added for and the whole-map query printed a
   phantom row. Strictly worse than the nothing it replaced. The -File path
   I had verified by hand was fine; the two call sites do not fail alike.

3. The unresolved-notice throttle was repo-wide. The stamp lives in the
   SHARED git-common-dir and production invokes the gate with no arguments,
   so the first session to hit a broken gate silenced it for every other
   session -- and those sessions read that silence as "checked, nobody is
   here", which is the precise defect the notice exists to remove. One
   session's diagnostic must never become another's false all-clear. Keyed
   per worktree now.

4. An empty payload or a literal `null` on stdin does not throw, so that
   was the one unreadable-input path still exiting silently.

5. A ghost session could outrank a live one. UNVERIFIED is the shape a
   crashed session's record takes once its pid is recycled; last-write-wins
   had no opinion about which record it kept for a directory, so a ghost
   could supply the id and branch reported for a worktree somebody is
   really sitting in. Fenced records now win, then sorted cwd.

Each fix is pinned, and the two regressions were checked against the
unfixed code: the phantom-row test sees `[null]`, and the claim test
asserts the file name never disappears while a refresh is failing.

* docs(worktrees): "is it live yet" has two answers, and they are different

I broadcast a merged claim.ps1 improvement to seven sessions as something
they could use immediately. A peer tried it, got the old behaviour, and
measured why: claim.ps1 is invoked BY HAND from the session's own worktree,
so it runs that worktree's copy, and their branch predated the change. The
in-force check I had given them was for the hook-run path and returned 0
for them.

Both halves of what I said were individually true. The combination was
wrong, because there are two rules and I collapsed them into one:

  hook-run   (collision_gate.ps1, and overlap.ps1 as its callee) -- the
             installed shim resolves the PRIMARY first, so it is live when
             the primary advances, whatever any branch contains
  hand-run   (claim.ps1, overlap.ps1, presence.ps1) -- resolved from the
             session's OWN tree, so it is live when that branch has it,
             and the primary is irrelevant

Tabulated, with the check spelled out per path. The point generalises past
this PR: test the property where the script will actually run from, because
a token that resolves in the primary says nothing about a hand-run script.

Also surfaces `collision_gate.ps1 -PathOverride <path>` as the read-only
"who holds this file right now" query. It is documented in-script only as a
test affordance, and the peer above found it by reading the source after it
answered a question nothing else would.

Both points are theirs, not mine.

* docs(worktrees): the freeze bullet had the right lesson and the wrong reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.

* docs(worktrees): put the two numbers behind the freeze bullet, with their sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.

* docs(ledger): the CI backstop does not re-check ownership, and said it did

Found while unblocking another session that could not commit a rescued ADR:
its number is allocated to a worktree that is not theirs.

LEDGER-GATE.md §3 said "CI re-runs the same rules with --ci", and Limits
said the --ci leg "is the backstop, and it cannot be bypassed from a
branch". Both are true of every rule except the one a reader is most likely
to be relying on. ledger_check.py:196 and :241 are each guarded by
`not self.ci`, so "was this number allocated to you" runs LOCALLY AND NEVER
IN CI.

It has to be that way, and the reason is worth keeping: owns() reads the
allocation store from <git-common-dir>/mefor-coord/alloc, and a CI runner
clones fresh with no store, so the check would return False for every ADR
and no ADR could ever merge. This is not a bug to fix. It is a limit that
was documented as its own opposite.

The consequence is now stated rather than left as an inference: a green CI
on an ADR or BACKLOG PR is NOT evidence the number was allocated to anyone.
And the residual is bounded in both directions -- after --no-verify a number
belonging to another session's unmerged branch can be committed with nothing
objecting, but the collision rule still blocks whichever of the two merges
second. Late, loud and recoverable, rather than silent, which is the
property the gate was actually built for.

Same defect class as the freeze bullet corrected two commits ago, and as the
collision gate this PR started with: a compensating control resting on a
false premise -- CLAUDE.md §11 -- this time inside the document describing
the control.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…n adjacent question

The three rules in Secure_Development_Standards §3 catch prose that is TRUE and
misleading. They do not catch the failure that produced eleven retractions across
four parallel sessions on 2026-08-02: a claim that is FALSE when written while
feeling measured, because the instrument answered a question adjacent to the one
asked.

The eleven, each verified by the session that made it:

  git diff on a STAGED file      "unstaged delta?"        vs "is the tree dirty?"
  merge-base --is-ancestor       "is this an ancestor?"   vs "did this land?"  <-- squash-merge: always no
  a hash INEQUALITY              "are these different?"   vs "is the copy WORSE?"
  session-start banner           "who was live then?"     vs "who is live now?"
  grep -c $'\r$' on git diff     "does the diff render CR?" vs "does the FILE have CRLF?"
  $? after `cmd | tail`          "did tail succeed?"      vs "did the gate pass?"
  Actions ?filter=latest         "latest attempt?"        vs "what did the suite ever do?"
  JOB conclusion                 "did the job pass?"      vs "did the STEP pass?"

Two findings that make it actionable rather than a scolding:

  - Re-reading caught NONE of the eleven. A check that could fail caught one
    immediately. Re-reading confirms what you meant; it cannot test what you wrote.
  - None was a stale fact. Every one was wrong at birth. "#119 never merged (it
    died on a CI timeout)" was never true at any instant -- that PR's timeline
    carries exactly one `closed` event, simultaneous with `merged`. So dating a
    claim does not protect against this class; only re-deriving it does.

Hence the rule is a PROPHYLACTIC, checkable before the sentence exists and without
a peer: name the question, name what the instrument returns, confirm they are the
same sentence.

Also adds the one-liner to CLAUDE.md §11 alongside the other three, per the
provenance note's own reasoning -- an instruction that short cannot drift, and a
pointer nobody follows mid-task changes no behaviour.

No version-history row: the "Reviewing security prose" subsection carries none
(added in 39990f8 without one), so additions there set no bump precedent. No
change to the SSDF / ASVS / HIPAA mappings.

Named by the repo-security-review session, which applied it to its own four
retractions and found four for four; instances contributed by the
ci-margin-correction, announce-hook, sandbox-codec and ADR 0154 sessions.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ore it is cut off (#152)

* fix(coord): overlap gave two different answers the same bytes, twice

Two defects in one script, and they are the same defect: a signal that
cannot distinguish the state it reports from a different state.

1. A -Json query answered "nobody else is in this file" by printing
   NOTHING. `@() | ConvertTo-Json -AsArray` sends zero objects down the
   pipeline, so ConvertTo-Json never runs -- -AsArray only shapes output
   that already exists. On stdout an all-clear was therefore byte-for-byte
   identical to the script dying before it answered, and no consumer could
   tell them apart. Every -Json exit now goes through one emitter that
   always produces an array. (-InputObject is not the fix: with -AsArray it
   double-wraps to [[]].)

   Found by running the real script against the real collision gate rather
   than the test stubs, which had been written to a shape the real script
   never produced.

2. A live session was attributed to a worktree by FIRST prefix hit. Linked
   worktrees live under the primary checkout, so every linked path is also
   a prefix match for the primary's row: the primary was handed whichever
   nested session the hash table enumerated first, and reported LIVE on
   main, "building" a peer's task list. Hash order is not stable, so it was
   a different wrong answer each run -- which is why it read as noise
   rather than as a bug. Longest prefix wins is the only rule that survives
   nesting, and it is resolved once against every worktree instead of per
   row.

   docs/WORKTREES.md already named this exact trap for the announce hook's
   id rule, where the cure was "never match by prefix". Here a prefix match
   is genuinely required -- a session may sit in any subdirectory -- so the
   cure has to be longest-prefix instead.

Both are pinned against a real nested-worktree git fixture; a sibling
layout would pass under the old rule and prove nothing. Each new assertion
was checked against the unfixed script first: the attribution test reports
the primary as Live/main/<peer session id>, and the array test sees ''.

* fix(coord): the collision gate reported an all-clear when it had checked nothing

Every fail-open path in this hook -- overlap script missing, throwing, or
printing garbage -- exited 0 with EMPTY STDOUT. On a PreToolUse hook whose
stdout is parsed as a decision, empty stdout means "allow", which is
byte-for-byte what "checked, nobody else is in this file" looks like. So a
gate that had consulted nothing was indistinguishable from a gate reporting
all-clear, and its own failure reached the session as reassurance.

That is the silent-control class this repo has now hit five times, and it
is the same shape as the wired-but-inert announce shim: the surface that
was supposed to report sat downstream of the failure it existed to detect.

The posture does not change -- every one of these paths still ALLOWS. Only
the silence does. It now emits a hookSpecificOutput.additionalContext
notice naming which reason (overlap-missing / overlap-failed /
overlap-empty / overlap-unparseable / payload-unreadable). It must be that
JSON shape and never a bare line: this hook's stdout is a decision, so a
stray line risks a misparse on every Edit and Write -- a diagnostic that
would be a worse fault than the one it reports. There is deliberately no
permissionDecision key: a notice that blocked would invert the fail-open
posture that is the whole point of this gate.

Rate-limited per reason (30 min, -NoticeCooldownMinutes) so a persistently
broken overlap cannot narrate itself into every edit -- this gate's own
docstring records where a gate that cries wolf ends up. The stamp lives
under -StateDir, defaulting to the repo's coordination dir and resolved
ONLY when about to report, so nothing new runs on the hot path. If the
stamp cannot be read or written the notice is emitted anyway: the failure
mode of a noise-suppressor must be noise, never quiet, or an unwritable
directory silently restores exactly the behaviour this removes.

Distinguishing overlap-empty from a resolved "nobody" required fixing the
producer first (previous commit) -- you cannot detect a difference the
producer never encoded. Verified against the real overlap script, not only
the stubs: an ordinary edit to an untouched file is silent.

Tests: -StateDir isolates the throttle per test, or the first notice would
silence the next test's and the suite would pass on run order.

* fix(coord): claim.ps1 accepted a new note, reported success, and discarded it

-Take documented itself as idempotent -- "re-taking your own claim just
refreshes the note" -- and did not refresh anything. A new -Note was taken,
acknowledged and dropped.

That is worse than an outright failure, because of what the note is for.
It is the only field written deliberately to say what a session is doing,
and announce-session.ps1 broadcasts it to every session joining the repo
while telling them to prefer it over the worktree name. So the one field
elevated to authoritative was the one field that could not be corrected.
Measured 2026-08-02: a claim note was still announcing "NO PR OPENED --
honouring the #119 merge freeze" to every joining session hours after both
that PR and the one it gated had merged.

The workaround people reached for -- -Release then -Take -- drops the claim
in between, re-opening the race the claim exists to close.

Re-taking a key you hold now rewrites the file in place: note, branch (a
worktree can have switched branches, and a claim naming a branch nobody is
on is another confidently-wrong coordination fact) and a new `refreshed`
stamp, leaving `claimed` untouched -- which is what proves the claim was
never let go. Write-then-rename, not a truncating write: claim_check.py
swallows a JSON parse error into "not claimed", so a torn file is a
silently disabled gate, and a crash mid-refresh must leave the old note.
Mutual exclusion is unchanged and pinned: a peer's key is still refused.

One trap found by the test rather than by reading. ConvertFrom-Json
silently coerces an ISO-8601 string to [datetime], so [string]$c.claimed
returns the local short form -- sub-second precision and UTC offset gone.
Writing that back would have downgraded the stamp on every refresh, and it
would still have parsed, so nothing would ever have complained. Stamps now
round-trip through "o", and the test asserts byte equality rather than
"still parses". The same coercion is handled where announce reads it, with
an invariant-culture parse for the string case.

announce-session.ps1 now prints each claim note's AGE (from `refreshed`
else `claimed`, "age unknown" when it cannot be determined -- an unknown
age must not render as a fresh one). Elevating a note to authoritative
makes a stale one strictly more dangerous than none, and age is the cheap
signal that lets a reader discount it.

Not taken here: claim -List's staleness-vs-liveness rendering, which is
already open as its own change.

* docs(coord): record the three fixes, and correct a claim that has expired

SESSION-DRIFT-CONTROLS.md: a fifth instance of the silent-control class,
in the collision gate itself, added to the callout that names the class.
It carries the part worth reusing -- the fix was not "check harder", it was
giving two states different bytes, and the first attempt failed because the
PRODUCER had never encoded the difference. Status-table rows for the three
controls, and the claim-refresh behaviour beside claim.ps1's entry.

WORKTREES.md: the announce id rule already warned that a prefix match
resolves a peer in the primary to an arbitrary worktree session, because
every worktree cwd extends the primary's. overlap.ps1 had that same trap
live at the same time. Noted there, with the distinction that matters:
overlap genuinely needs a prefix match, so the cure is longest-prefix
rather than exact-match.

And a correction. The broadcast-constraints list said of last week's merge
freeze that "#119 never merged (it died on an unrelated CI timeout)". It
merged the following day, 2026-08-02 01:45Z. Verified against the API
rather than restated. The lesson is unchanged and in fact sharper: the
recipients could not evaluate the predicate, so the freeze outlived its own
condition in both directions -- five sessions held while it had not
arrived, and a claim note was still announcing it hours after it had.

* docs(coord): announce-on-join merged and was never installed

Found while checking a peer session's report, not by looking for it. That
session announced itself by hand on 2026-08-02 and gave the reason as "the
hook is on an unmerged branch". It had merged (#133, 3389aa2) hours
earlier, so the observation was right and the diagnosis was not, and
nothing would have corrected it.

Measured across all five config roots:

  - no `mefor-announce` UserPromptSubmit entry anywhere
  - the one UserPromptSubmit entry installed is `# mefor-web-announce`,
    which resolves scripts/hooks/announce.ps1 -- a different script in a
    different repo, and one the installer's own comment already warns is
    easy to confuse with this marker
  - <git-common-dir>/mefor-coord/announce/ does not exist, so there is not
    a single receipt: it has never executed

install-coordination.ps1 was last run before the announce row existed, and
merging a hook does not install one. Its two other entries -- the
SessionStart banner and the collision gate -- were wired then and are
present, which is precisely why nothing looked wrong.

The part worth carrying: the missing-script notice was built so this class
could not hide, and it CANNOT FIRE when the hook is not wired at all,
because it lives inside the shim. Same shape as the defect this document
already records one level down -- the detector sat downstream of the
failure it existed to detect. So the status table now distinguishes rule
4's inert-BY-DESIGN from this one's inert-BY-ACCIDENT, and the confirmation
step is a receipt on disk rather than a reading of the settings file.

Not installed here: that writes ~/.claude/settings.json, which is shared
with every session on this machine. Owner's call, from a plain terminal.

* fix(coord): five defects this PR's own first pass introduced or left

Found by an adversarial review of the preceding commits, then each one
reproduced by execution before being touched. Two were regressions I had
introduced; three were gaps.

1. THE CLAIM FILE'S EXISTENCE IS THE LOCK, and the refresh unlinked it.
   `Move-Item -Force` is delete-then-rename. The take path is an exclusive
   CreateNew, so any instant the name does not exist is an instant another
   worktree can claim a key we hold -- i.e. the note refresh could hand a
   claim away. Measured on this box: 400 moves left the destination absent
   on 2,559 of 154,506 polls. [IO.File]::Move with overwrite is
   MoveFileEx(MOVEFILE_REPLACE_EXISTING), and the same harness never once
   saw the name missing across 134,581 polls. It fails transiently instead
   (13.5% under back-to-back churn, nothing like one refresh per run), so
   it retries five times and then reports; failing is the safe direction --
   the old note survives and the claim stays ours.

   The catch around it is deliberately UNTYPED: PowerShell wraps a .NET
   method's exception in a MethodInvocationException, so the typed catch I
   wrote first never matched, the failure escaped to ErrorActionPreference
   = Stop, and the temp file was orphaned in the claim registry. The
   orphaned-temp assertion is what caught it.

2. `overlap.ps1 -Json` emitted `[null]` for an empty map. Build-Map returns
   AutomationNull, which PARAMETER BINDING converts to a real $null at the
   call -- and `@($null).Count` is 1, so the zero-rows guard was dead in
   exactly the case it was added for and the whole-map query printed a
   phantom row. Strictly worse than the nothing it replaced. The -File path
   I had verified by hand was fine; the two call sites do not fail alike.

3. The unresolved-notice throttle was repo-wide. The stamp lives in the
   SHARED git-common-dir and production invokes the gate with no arguments,
   so the first session to hit a broken gate silenced it for every other
   session -- and those sessions read that silence as "checked, nobody is
   here", which is the precise defect the notice exists to remove. One
   session's diagnostic must never become another's false all-clear. Keyed
   per worktree now.

4. An empty payload or a literal `null` on stdin does not throw, so that
   was the one unreadable-input path still exiting silently.

5. A ghost session could outrank a live one. UNVERIFIED is the shape a
   crashed session's record takes once its pid is recycled; last-write-wins
   had no opinion about which record it kept for a directory, so a ghost
   could supply the id and branch reported for a worktree somebody is
   really sitting in. Fenced records now win, then sorted cwd.

Each fix is pinned, and the two regressions were checked against the
unfixed code: the phantom-row test sees `[null]`, and the claim test
asserts the file name never disappears while a refresh is failing.

* docs(worktrees): "is it live yet" has two answers, and they are different

I broadcast a merged claim.ps1 improvement to seven sessions as something
they could use immediately. A peer tried it, got the old behaviour, and
measured why: claim.ps1 is invoked BY HAND from the session's own worktree,
so it runs that worktree's copy, and their branch predated the change. The
in-force check I had given them was for the hook-run path and returned 0
for them.

Both halves of what I said were individually true. The combination was
wrong, because there are two rules and I collapsed them into one:

  hook-run   (collision_gate.ps1, and overlap.ps1 as its callee) -- the
             installed shim resolves the PRIMARY first, so it is live when
             the primary advances, whatever any branch contains
  hand-run   (claim.ps1, overlap.ps1, presence.ps1) -- resolved from the
             session's OWN tree, so it is live when that branch has it,
             and the primary is irrelevant

Tabulated, with the check spelled out per path. The point generalises past
this PR: test the property where the script will actually run from, because
a token that resolves in the primary says nothing about a hand-run script.

Also surfaces `collision_gate.ps1 -PathOverride <path>` as the read-only
"who holds this file right now" query. It is documented in-script only as a
test affordance, and the peer above found it by reading the source after it
answered a question nothing else would.

Both points are theirs, not mine.

* docs(worktrees): the freeze bullet had the right lesson and the wrong reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.

* docs(worktrees): put the two numbers behind the freeze bullet, with their sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.

* docs(ledger): the CI backstop does not re-check ownership, and said it did

Found while unblocking another session that could not commit a rescued ADR:
its number is allocated to a worktree that is not theirs.

LEDGER-GATE.md §3 said "CI re-runs the same rules with --ci", and Limits
said the --ci leg "is the backstop, and it cannot be bypassed from a
branch". Both are true of every rule except the one a reader is most likely
to be relying on. ledger_check.py:196 and :241 are each guarded by
`not self.ci`, so "was this number allocated to you" runs LOCALLY AND NEVER
IN CI.

It has to be that way, and the reason is worth keeping: owns() reads the
allocation store from <git-common-dir>/mefor-coord/alloc, and a CI runner
clones fresh with no store, so the check would return False for every ADR
and no ADR could ever merge. This is not a bug to fix. It is a limit that
was documented as its own opposite.

The consequence is now stated rather than left as an inference: a green CI
on an ADR or BACKLOG PR is NOT evidence the number was allocated to anyone.
And the residual is bounded in both directions -- after --no-verify a number
belonging to another session's unmerged branch can be committed with nothing
objecting, but the collision rule still blocks whichever of the two merges
second. Late, loud and recoverable, rather than silent, which is the
property the gate was actually built for.

Same defect class as the freeze bullet corrected two commits ago, and as the
collision gate this PR started with: a compensating control resting on a
false premise -- CLAUDE.md §11 -- this time inside the document describing
the control.

* feat(coord): publish the account's plan limits so a session knows before it is cut off

Sessions were hitting the plan limit mid-task and losing work. The real
quota state exists -- Settings > Usage shows it -- but nothing inside a
session could see it.

WHERE THE NUMBERS COME FROM, because it determines the whole shape. Claude
Code hands `rate_limits` to a statusLine command's stdin and NOWHERE ELSE;
the hook payloads were enumerated in the shipped binary and it appears in
exactly one of them. Quota state therefore cannot be subscribed to. It has
to be collected by a statusLine and published somewhere shared, which is
why this is scripts/coord/usage-collect.ps1 and not a hook.

ONE PUBLISHER, N READERS. The quota is account-wide, so any one session's
reading is true for all of them. The publish path is user-level because the
data is a property of the ACCOUNT, not of a checkout. Summing across
sessions would double-count one shared pool.

Three defects found by testing rather than by reading, each now pinned:

  - AN EMPTY READING CLOBBERED A GOOD ONE. Every session runs the
    statusLine, so every session is a publisher; one that has not yet had
    its first API response carries no rate_limits and blanked the account's
    only reading for all of them. Windows are absent INDEPENDENTLY per the
    docs, so the carry-forward is per window and keeps each window's own
    captured_at -- a stale number must not wear a fresh timestamp.
  - HISTORY MUST RECORD ONLY FRESH OBSERVATIONS. A carried-forward
    percentage against a new timestamp tells the burn rate that
    consumption stopped, which is the one lie that matters here.
  - RATE MUST NOT SPAN A WINDOW RESET. The percentage legitimately
    collapses at the boundary; a rate across it is large and NEGATIVE.
    Mutation-checked: removing the epoch filter yields -101.63 %/hr at the
    exact moment a fresh window starts being spent.

And a fourth, which is the same ConvertFrom-Json date coercion that
downgraded the stamp in claim.ps1: captured_at arrives already typed as a
[datetime]. Stringifying it drops the 'Z', re-parsing assumes local, and a
reading taken 90 seconds earlier reported as 299 minutes IN THE FUTURE --
exactly this machine's UTC offset. The sign is what made it dangerous: a
negative age passes an `age -gt max` test unconditionally, so the staleness
guard would have been disarmed on every non-UTC machine while still looking
present. Bounded both ways now.

WHAT IT CANNOT SEE, printed on every run rather than buried: the per-model
weekly buckets (Fable/Opus/Sonnet) and the plan tier are not in the payload
at all, and the request to expose them was closed as not-planned. If Opus
is burned hard across many sessions, the bucket most likely to stop you is
the one this cannot report. Two green bars and an invisible third is worse
than no tool.

Exit codes 0/10/11/20 so a coordinator branches without parsing prose.
UNKNOWN is a real answer and is returned for stale, undateable or
future-dated readings; nothing is ever extrapolated from a dead publisher,
and the statusLine does not run headless, so a dead publisher is the
expected steady state for the coordinator itself.

Not built on ccusage: it measures tokens and dollars, not plan limits,
despite being the tool everyone recommends and several summaries claiming
otherwise. Its own docs contradict them.

Not installed here -- it writes user-level settings shared by every session
on the machine, so that stays the owner's call from a plain terminal.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…the job cap from maxima (#138)

* ci: the margin table in #131 was wrong in every row, and the job cap had already fired

#131 replaced a "~2x headroom" claim with a measured table. The table was
itself wrong -- every row, each in the safe-looking direction -- and the job
cap it left in place was already negative.

MEASURED over all 101 CI runs created 2026-08-01 (`gh api --paginate`),
timing each leg's `Tests (pytest)` STEP and filtering on the STEP's own
conclusion:

  leg              claimed        true (n)          old cap   true margin
  ubuntu-latest    12:27          12:31  (n=57)     19:00     1.518x
  windows-2022     18:39          21:34  (n=52)     26:00     1.206x
  windows-2025     24:35          25:51  (n=49)     26:00     1.006x

Nine seconds, not 85. Two mechanical causes, both cheap to repeat:

  * THE POOL WAS A PAGE, NOT A QUESTION. It came from `gh run list --limit
    20` -- a default-sized listing reported as though the sample had been
    chosen. 20 of 101.
  * FILTERING ON *JOB* CONCLUSION DROPS THE TIGHTEST STEPS BY CONSTRUCTION.
    A step near step_timeout is the one most likely to push its job into
    job_timeout, so the job is cancelled while the step concluded success.
    Five such rows exist that day and they include the maximum.

Only the windows-2025 row was ever a maximum; ubuntu's and windows-2022's
figures were that same run's other two legs.

AND THE TWO-GATED-STEPS HAZARD IS NOT LATENT -- IT FIRED. Both `Tests
(pytest)` and `Web console tests (pytest)` carry `step_timeout`, so a job can
hold 2x step_timeout of gated work that step_timeout cannot bound. Run
30724385719 (main @ 8f01cef):

  Tests (pytest)              25:51  SUCCESS    (9s under the 26:00 cap)
  Web console tests (pytest)         CANCELLED
  JOB                         30:13  CANCELLED  <- job_timeout 30 fired

A green first step, then an unattributed job-level kill during the second --
precisely what ci.yml:218's nesting note exists to prevent, by the path it
does not consider. It cannot happen when a step is KILLED (that ends the job
and skips what follows), only when the first step PASSES near its budget.

Sizing job_timeout to hold both gated steps plus setup, rather than
step_timeout plus a constant:

  leg       step + web(max) + overhead   old job      new job
  ubuntu    19:00 + 1:58 + 0:41 = 21:39  22:00 +21s   26:00 +4:21 (1.20x)
  W22/W25   36:00 + 3:27 + 0:41 = 40:08  40:00  -8s   46:00 +5:52 (1.15x)

All three legs sat inside a minute of their job cap and Windows was already
negative: the +4 convention was carried through two cap changes without
anyone summing what it had to cover.

The 36:00 step decision is unchanged and remains correct -- 1.393x over the
true 25:51 maximum, still comfortably above the 5:26 observed spread. Only
its justification moves.

Found by the ADR 0158 verification pass (eight agents re-deriving every
claimed number against the API; 50 claims checked, 6 refuted), reported by
the intersession-communication-hooks session, and re-derived here before
acting. BACKLOG #344 still restates the superseded figures; that edit is
blocked on three live sessions holding docs/BACKLOG.md and follows separately.

* docs: handoff for ci-margin-correction

Written at the owner's stop-work instruction (usage cap). Chat does not
survive; a claim that lives only in a transcript reaches nobody.

Carries: #138's state and its SQL Server blocker (NOT called a flake --
unproven), the corrected margin table, four retractions of my own findings
with their corrected forms, and seven traps stated as fact-plus-measurement.

The load-bearing line: the cap raise in #131 is correct and unchanged; its
justification was wrong in every row, and #138 fixes it.

* ci: re-measure the margin table a third time, and size the job cap from maxima

The correction in this PR fixed #131's maxima but repeated #131's own failure
mode in a new place: it reported a pool and an n that its stated pool cannot
produce. Two independent re-derivations agree the day holds 70 ci.yml runs, not
101, and per-leg n = 42 / 39 / 36, not 57 / 52 / 49. The three MAXIMA
(12:31 / 21:34 / 25:51) re-derive to the second and are unchanged, so the
36:00 step / 46:00 job decision stands on the same numbers it always did.

What actually changed:

* POOL AND n. 70 runs created 2026-08-01 UTC, cross-checked four ways;
  n = 42 / 39 / 36. A table whose own point is "state your pool and your n"
  has to carry an n the next reader can recompute.

* THE POOL IS RIGHT-CENSORED, and nothing said so. Every run in it predates
  #131 (28d186b, 2026-08-02T00:35:28Z), so each observation survived a 26:00
  cap. 25:51 is the largest step that FIT, not the largest the suite wants, and
  a multiple of a censored maximum under-provisions by construction. This is
  why 1.06x read as survivable right up to the moment #119 died.

* THE JOB-CAP ADDENDS WERE TYPICAL VALUES, NOT MAXIMA. 0:41 is the median
  setup; the web-console figures 1:58 / 3:27 are each only third-highest on
  their leg. Measured maxima are 2:00 / 2:33 / 3:33 (web) and 1:20 / 1:09 /
  1:04 (setup). Re-summed, ubuntu was -0:20 and Windows -0:37 against the old
  caps: BOTH were already negative, not just Windows.

* THE NESTING INVARIANT IS NOT RESTORED, and now says so. It holds for
  `Tests (pytest)` on every leg and for `Web console tests (pytest)` on none:
  reaching that step already spends setup plus `Tests`, so its own cap can
  never fire first. A hang there still surfaces as an unattributed job kill.
  Stating otherwise would rest a compensating control on a false premise.

* The "5:26 spread ... identical code" claim is withdrawn; neither endpoint
  re-derives. The true in-pool spread is 9:55, which is wider and supports the
  decision more strongly than the figure it replaces.

Also corrects three pre-existing claims in this file that match no
configuration -- the same defect class this note is about:
  - the pytest step's cap described as `timeout-minutes: 13` (it is
    matrix.step_timeout, 19 or 36)
  - "the 15-min job cap" (it is matrix.job_timeout, 26 or 46)
  - the mechanical margin guard cited as BACKLOG #341, which does not exist on
    main; it is #344 item 1

Drops HANDOFF-ci-margin-correction.md: session ephemera, and no HANDOFF file
has ever been tracked at the repo root on main.

* ci: mark the job-cap exhibit as mechanism, not verdict

Run 30724385719 ran under the retired 26/30 pair and would have passed under
#131's 40:00. It demonstrates that two steps sharing one step_timeout lets the
job cap fire behind a green step; it is not itself evidence that 40:00 is too
tight. That case rests on the arithmetic, which is arithmetic -- no job has
been observed hitting 40:00.

* backlog: correct #344's own figures, and file the job cap as instance 3

#344 is the item about bounds stated independently of the work they bound. Its
instance 1 still carried the retracted measurement -- 24:35 over "11 runs",
1.06x, raised to "1.46x" -- every figure of which was superseded, and the edit
had been outstanding since it was filed.

Instance 1 now reads 25:51 / 1.006x / 1.393x, and carries its pool (70 ci.yml
runs created 2026-08-01 UTC; per-leg n = 42 / 39 / 36) so the next reader can
recompute it rather than trust it.

Adds two things the re-measurement turned up:

* INSTANCE 3 -- the CI job cap. Two steps in that job carry step_timeout, so
  job_timeout must cover their sum, and the +4 convention that sized it was
  never summed against what it had to hold. Recomputed from measured maxima,
  ubuntu was -0:20 and windows-2025 -0:37: both already underwater. It presents
  as a GREEN first step followed by an unattributed job-level kill, which is a
  signature instance 1's own proposed margin check would not catch, because the
  step it measures passed.

* THE CENSORING TRAP. A max-passing figure is a LOWER BOUND: the pool is
  censored by whatever cap was in force when it was collected, so the runs that
  would have exceeded it were killed and are missing from exactly the tail being
  measured. This is why 1.006x looked survivable until #119 died.

Proposal 5 records the structural fix instance 3 does not make: give the
web-console step its own cap, because until then ci.yml's nesting invariant is
unenforceable for the second gated step on every leg.

Banner moves from "not started" to PARTIAL -- instances 1 and 3 are fixed,
instance 2 is not. One banner, still OPEN.

Also records, once, that instance 1's figures have now been published wrong
twice; the maxima survived both passes and the pools did not.

* ci: fix what the verification pass found in my own correction

Nine agents re-derived this block; two re-derived the table from scratch under
instructions to refute it. They confirmed every figure in the step table and
refuted six things written around it. Fixing my own text, since the whole point
of this change is not to ship a third unchecked table.

* 26:07 EXISTS, and I said it did not. It is in this very pool -- run
  30717229521 attempt 1, sha 8c407fb, step conclusion FAILURE, killed at the
  26:00 cap -- hidden because the jobs endpoint defaults to `filter=latest`,
  which returns only the passing attempt-2 re-run. Same filter hid seven
  same-commit pairs, so the "no identical-code spread is computable" claim was
  also wrong. `?filter=all` shows both.

* THE SPREAD RULE IS NOT MET, and I claimed it was. The first day after the
  raise produced 26:23 TWICE, both concluding SUCCESS -- uncensored evidence
  that the population exceeds the old 26:00 cap. Against 26:23 the headroom is
  9:37 and the spread 10:27, so "headroom must exceed observed spread" FAILS at
  36:00; it would need ~37:00. 36:00 is kept and the reason is now stated
  plainly -- this cap catches a deadlock, not slowness, and 1.365x over the
  worst observed run is ample for that -- rather than the rule being asserted as
  satisfied. Re-derive if a windows-2025 step is ever seen above 28:00.

* WINDOWS setup(max) IS 1:20, NOT 1:04. 1:04 came from restricting to rows where
  BOTH gated steps succeeded, which drops the exhibit run printed 20 lines above
  (its web-console step was cancelled) -- the same censoring mistake as filtering
  the step table by job conclusion, made again. W25 is 40:53, so the old cap was
  -0:53, not -0:37. windows-2022 now gets its own row with its own addends
  instead of an unrecheckable 39:42.

* "the +4 convention" was Windows-only. ubuntu went 15/13 -> 22/19, so +2 then
  +3, never +4.

* "2 x step_timeout ... which job_timeout must cover" asserted a requirement the
  shipped caps do not meet (38 > 26, 72 > 46). Now says what they are actually
  sized against.

* The nesting invariant at the top of the block asserted a guarantee it does not
  provide for the SECOND gated step on any leg. Amended there, where a reader
  meets it, not only in a caveat 100 lines below.

Also: pytest_timeout / fault_timeout were quoted as flat 60s / 90s in three
places; they are matrix values (60/120 and 90/150) passed explicitly on the
command line, so each figure was false on two of three legs. And records that
`test` is the only one of this file's ten jobs with any cap at all -- the other
nine run on GitHub's 6h default.

* backlog: #344 instance 3 carried the superseded -0:37, and the censoring now has evidence

The windows-2025 job-cap shortfall is -0:53, not -0:37: the 1:04 setup addend it
was computed from excluded the exhibit run itself, because that run's
web-console step was cancelled. Same censoring mistake, one layer down.

Instance 1 now cites the uncensored observation rather than only arguing the
maximum must be a lower bound: the day after the raise, windows-2025 produced
26:23 twice, both passing -- runs the old 26:00 cap would have killed. Records
that the jobs endpoint hides a killed attempt behind its passing re-run unless
asked for ?filter=all, which is why nobody had seen them.

* ci: #119 merged — stop saying it "died"

Reported by the announce-hook session, verified here: gh pr view 119 gives
MERGED, mergedAt 2026-08-02T01:45:00Z. What the 26:00 cap killed was a RUN, not
the PR.

"#119 died" reads as never-landed, and that reading had already propagated --
docs/WORKTREES.md asserted #119 "never merged (it died on an unrelated CI
timeout)", which that session corrected against the API. This block was one of
the places the phrasing came from, so it is fixed at the source rather than
only downstream.

* ci: 36:00/26:23 is 1.364x, not 1.365x

Caught by running an exact-arithmetic assertion over every figure in the block
rather than re-reading it: 2160/1583 = 1.3644978, which rounds to 1.364. I had
rounded it up by hand in both places.

Trivial in size and not in kind -- this is a change whose entire subject is
numbers published without being recomputed, so it does not get to ship one.
Every ratio, sum and delta in the block is now covered by that assertion and all
fifteen are exact.

* backlog: #344's own proposal 3 was harmful, and instance 2 was mis-diagnosed

Found by investigating a sqlserver failure on this very PR, which turned out to
be instance 2 recurring on a different test.

PROPOSAL 3 IS WITHDRAWN. It said a poll deadline over a virtual-clock system
should follow that clock rather than loop.time(). Implementing that would have
HUNG the suite: _wait_until waits on real store I/O, never on virtual time, and
ManualClock.now advances only inside advance(), which nothing calls from the
poll loop (tests/test_stage_dispatcher.py:182-204). A mc.now-based deadline is
never reached, so a bounded `assert False` becomes an unbounded hang stopped
only by pytest_timeout or the job cap -- manufacturing the exact signature
instances 1 and 3 are about. A virtual clock can only bound work it drives.

INSTANCE 2 IS RE-DIAGNOSED, and the original reading -- "the 8.0s bound is too
small" -- is refuted by the recurrence's own timings. The failing test took
8.185s, so _wait_until burned its full 8.000s and setup+teardown cost 0.185s:
the store was FAST when it failed. In the same process against the same
container the sibling retry_forever[sqlserver] drove seven identical fault
cycles in 0.364s, and the [sqlite] variant of the failing test passed in 0.144s.
A cycle costs ~30-45ms against an 8000ms bound. The lane is not slow to
transition; it never transitions. Raising the number would not fix it and would
bury it -- which is precisely the mislabelling this item's own Why warns about.

The leading mechanism is recorded as evidenced-but-unconfirmed rather than
asserted: a sanctioned EMPTY claim drops the lane to IDLE, and these tests
deliberately disable the sweep that recovers it in production. What is settled
is the negative: not latency, and not a bigger number.

PROPOSAL 1 now carries the evidence for preferring a computed gate to a written
instruction. Seven claims were retracted across this triage cluster and none was
caught by re-reading; every one fell to a mechanism that could return "no". The
one an author caught themselves was caught by running exact arithmetic over all
fifteen ratios in the block. As a method, re-reading is 0-for-7 here.

* backlog: #344 instance 2 inverts the item's own remedy, and needs observability first

A second, larger pass measured what instance 2 actually costs, and the numbers
change what should be done about it.

RATE AND MARGIN. The two affected tests fail 2 times in ~479 observations
(~0.4%), zero on postgres (119) and zero across 1,105 sqlite executions. Over a
sample of green sqlserver jobs the failing test passes in min 0.185s / median
0.196s / max 0.204s -- the 8.0s bound is ~39x its worst passing run, ~14x over a
wider 21-day pool. Both failures sit ~7x beyond the whole passing distribution:
a gap, not a tail.

SO THIS INSTANCE INVERTS THE ITEM'S OWN GENERIC REMEDY. "Size the bound against
the work" would derive ~1-2s here -- TIGHTER than the 8.0s already in place.
There is no larger number to justify, and raising it would only convert a 0.4%
visible failure into a 0.4% invisible 30-60s pause. An item about bounds that
have drifted too LOOSE has to be able to say when the answer is not a bigger
number, and this is that case.

MECHANISM IS EXPLICITLY UNRESOLVED. Two independent passes disagreed -- one
proposes a sanctioned EMPTY claim dropping the lane to a terminal IDLE (these
tests disable the sweep that recovers it in production), the other returned NOT
PROVEN and is right that the evidence cannot separate that from a genuine stall.
Recorded as unresolved rather than picking the more satisfying story.

NEW PROPOSAL 6: make the expiry diagnostic before tuning it. `assert await
_wait_until(...)` prints `assert False` and nothing else -- no phase, no park
deadline, no streak, no task state -- which is why this was read as latency for
a day. That is the prerequisite for judging any other proposal here, and unlike
them it cannot itself be wrong about the cause.

NOT changed: the note that a killed attempt hides behind its passing re-run
unless you pass ?filter=all. A reviewing pass claimed filter=all does not return
prior attempts; checked directly against both cited runs and it does
(attempts [1,2] on each), so the existing text stands.

* backlog: #344 proposal 6 — the instrument that settles instance 2 already ships

Both adversarial passes converged on the same verdict (raising the bound would
mask, not fix) but neither proposed the cheap discriminator, and it turns out
not to need building.

StageDispatcher.empty_claims (stage_dispatcher.py:1230) already returns
(total, wake_fanout, idle_poll) and is fed by _record_empty, whose ONLY call
site is the EMPTY branch of _claim_and_dispatch (:686). Under these tests'
topology -- lane_provider=set(), sweep_interval=3600, one seeded row -- a clean
run must read (0,0,0). So at the moment of failure `empty_claims[0] > 0` proves
a spurious EMPTY dropped the lane to a terminal IDLE (T12 sets phase=IDLE and
arms no timer), and `== 0` proves the claim never returned at all. One assertion
separates the two hypotheses this item currently records as unresolved.

A second signature costs nothing and is already in the captured log: a healthy
run emits FOUR `re-pending head with backoff` records (1001.000 / 1003.500 /
1008.000 / 1016.500, the ManualClock base plus the infra backoff ladder); the
failing run emitted ONE. The lane never took a second fault.

Verified against the source before citing it -- accessor, call site and the T12
branch all read directly, not taken from the analysis.

* backlog: two of my own #344 claims were wrong — postgres power, and a signature that does not generalise

Both reported by the session that settled instance 2 on a live SQL Server, and
both are defects in text I had already committed.

POSTGRES'S ZERO EXONERATES NOTHING. I wrote "zero on postgres (119
observations)" alongside sqlite's zero, which reads as two backends clearing the
mechanism. It is not: at ~0.4% a 119-observation sample expects ~0.5 events, so
zero is the expected outcome whether or not Postgres is affected -- and Postgres
claims via FOR UPDATE SKIP LOCKED, the SAME head-of-line skip. Only SQLite's 0
in 1,105 is structural, because its global lock totally orders producers and
claimers. Citing an underpowered sample as evidence of absence is the same error
as the censored maximum three paragraphs above it.

THE LOG SIGNATURE HOLDS FOR ONE TEST, NOT BOTH. I wrote that "a healthy run"
emits four `re-pending head with backoff` records against the failing run's one.
True of test_adr0070_1_* only. test_adr0070_9_* takes the content path, which
uses mark_failed and never emits that line at all, so zero there is EXPECTED and
is not evidence of a second mechanism. Scoped, with the reason, and pointed at
the counter rather than the log.

Proposal 6 itself is vindicated: empty_claims settled it in one assertion, read
(1,0,1) forced deterministically against a live SQL Server. The mechanism write-
up belongs to that session; I have corrected only my own two claims and left
instance 2's resolution to them.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ready pointed at (#145)

* feat(coord): announce yourself to the other sessions in this repo

Every coordination control in this repo is PULL-based: a new session discovers
its peers from the SessionStart banner and the peers learn nothing until someone
trips the collision gate. That is too late for the collision that costs the most
-- two sessions building the same THING in different files, where nothing
file-shaped can catch it. This closes the push direction.

It ASKS, it cannot send. Hooks are shell commands and session messaging is MCP,
so the hook prints the instruction, the live peer roster and the id-resolution
rule at the first prompt that has intent to report; the model does the sending.

UserPromptSubmit, not SessionStart: at SessionStart a session knows it exists and
nothing else, so it can only say hello -- the interrupt without the information.

THE ID RULE IS THE PAYLOAD, and it is counter-intuitive enough that the text
states it with its evidence. The registry id in this repo's banners is NOT the
MCP session id; measured, a registry id and an MCP id for one session shared no
characters. Branch does not join them either -- the two rosters reported
different branches for the same checkout in 2 of 6 cases. Only cwd joins, and it
must be matched EXACTLY: every worktree cwd is an extension of the primary's, so
a prefix match resolves a peer in the primary to an arbitrary worktree session.
A registry id passed to send_message fails SILENTLY, which reads as the peer
ignoring you.

EVERY DECISION LEAVES A RECEIPT, because the bug being fixed was a hook that was
wired, fired, resolved nothing and exited 0 for weeks -- byte-identical to a
healthy hook with no peers. For the same reason the shim carries its OWN
missing-script notice: every receipt the hook writes lives INSIDE the script,
strictly downstream of the resolution failure that IS the bug, so the shim is the
one surface that still reports when the script does not resolve. It is gated on
presence.ps1 so the entry stays silent in every unrelated repo on the machine.

It always exits 0 -- a UserPromptSubmit hook that fails can block the user's
prompt. It consumes presence.ps1 and therefore the single liveness fence; it does
not invent a second notion of live. A separate 'mefor-announce' marker keeps it
outside install-coordination's mefor-coord strip and outside the website repo's
mefor-web-announce entry in the same settings file, so no installer can delete
another's hook, and -Only UserPromptSubmit -Uninstall removes announce alone
without disarming the collision gate.

* test(coord): pin the announce hook, and the anti-no-op wiring class

Most tests for a hook like this assert an ABSENCE, and a hook that does nothing
at all satisfies every one of them -- which is precisely the production failure
being fixed. So the silence assertions are paired with a positive arm: two tests
run the SAME runner against fixtures differing only in whether a peer exists, and
if the silence tests ever start passing for the wrong reason the positive one goes
red first.

test_announce_wiring.py is the class the repo had no test for AT ALL: does the
thing that gets INSTALLED reach a script that EXISTS, and does it say so when it
does not? Its absence is exactly how a wired-but-inert shim survived for weeks.
test_every_wired_script_exists_in_this_checkout was written FIRST and watched
fail, naming the missing script and printing all three paths it scanned; a green
gate is only evidence if it was shown it can see the failure.

Also pinned, each because it was got wrong somewhere first:

- The foreign UserPromptSubmit entries -- another repo's shim and an unmarked
  waiting-flag cleanup -- survive install AND uninstall byte-identical. That is
  the only thing standing between a one-line wiring edit and deleting a hook this
  repo does not own.
- A peer with no StartedAt ranks LAST, not first. ConvertFrom-Json coerces
  ISO-8601 to DateTime while the '' fallback stays String; Sort-Object over that
  mixed column raises ZERO errors and puts the empty string FIRST, so without an
  explicit projected key the least-trustworthy row silently takes the top of a
  capped target list.
- NO_SESSION_ID and DISABLED write their receipt with NO injected -StateDir. An
  earlier draft resolved the state dir after those branches, so the receipt was
  unwritable in production while a test that always injected one went green.
- Self is excluded by BOTH nets independently: a roster that cannot tell you from
  a sibling makes the session message itself.
- Hostile peer text cannot escape the peer-data block or emit a non-ASCII byte,
  a hostile session id cannot escape the state dir, and two ids that sanitise
  identically get two markers.
- Two concurrent runs announce exactly once. session-context.ps1 is registered
  twice on this box today, so double firing is a live pattern, not a hypothetical.

* docs(coord): document announcing yourself, and correct a false claim about .claude

WORKTREES.md gains the "Announcing yourself" section that the hook's own emitted
text and the shim's missing-script notice both cite by name, so the pointer has to
land on main in the same merge. It states the id rule ONCE, as the source of
record: registry id is not the MCP id, cwd is the only join key and must be
matched exactly rather than by prefix, a usable id starts with local_, and a wrong
one fails silently.

It also states what the change does NOT do. There is no receive-side hook, so the
rule that an announcement is peer DATA -- not an operator instruction, and not
something to reply to -- lives in the prose and in the fixed message shape and
nowhere else. Reachability is given honestly: presence.ps1 is authoritative for
who EXISTS, list_sessions only for who can be MESSAGED, and measured, they
disagreed 6-to-1. Cost is stated rather than left to be discovered.

CORRECTION, and it is why this doc change is in scope rather than deferred: the
same chapter claimed ".claude/settings.json is tracked (shared across worktrees)".
It is not. /.claude/ is git-ignored, and git ls-files .claude/ returns nothing --
so a worktree's copy is a creation-time snapshot nothing refreshes and several
siblings have none at all. That sentence sat at the exact point a reader decides
where to install a hook, and it argues for the wrong answer; the new section
directly contradicted it.

SESSION-DRIFT-CONTROLS.md records announce as the only PUSH control in the D4
layer, plus the two new guarantees worth tracking separately: that wiring reaches
a script that exists, and that a resolution failure is now reported by the shim.

* fix(coord): stop the collision gate blocking files a peer committed and finished

Reported by another session with a repro: it committed a file, went clean, said
in writing it was done and handed the file over -- and the peer it handed off to
was still refused the edit.

overlap.ps1's `Files` is the UNION of what a branch COMMITTED-and-not-yet-landed
with what is dirty in its tree. The gate denied on any live row in that set, so
"this branch authored it" was treated as "someone is typing in it right now".
Those are different claims. The first stays true for the branch's whole life;
only the second is what the gate exists to detect.

It self-clears on merge -- overlap already intersects three-dot with two-dot so a
LANDED branch stops claiming its files. But nothing clears it before landing, and
with PRs currently unable to merge, "until it lands" is indefinite: the blocked
set grows monotonically and is never released. Two sessions that coordinated
correctly and explicitly still cannot hand a file over. That is precisely the
failure this gate's own docstring names -- a gate that cries wolf gets
uninstalled.

overlap.ps1 already told callers to treat its signals differently ("block on
live, mention dormant"), but no caller COULD: the row unioned the two signals
away. So the row now carries `Dirty`, and the single-file query sets
`MatchedDirty` saying which signal actually matched.

The gate now DENIES only on an uncommitted edit in a live worktree, and REPORTS
committed-and-clean as context instead -- the peer may already have done what you
are about to do, which is worth knowing and not worth refusing over.

Fails SAFE across the upgrade: a cached row predating `MatchedDirty` has no such
property and is treated as dirty, so the gate degrades to its previous
over-blocking rather than silently permitting a real collision.

Also, while in the file: `git status` now runs with --no-optional-locks. A plain
status REWRITES the index of the repo it inspects, and this walks every peer
worktree -- so merely asking "what is in flight" was mutating other sessions'
checkouts.

Verified against the live repro and both directions: the reported file now
allows with context; a file with uncommitted changes in a live worktree still
denies; an untouched file stays silent.

* feat(coord): lead the announce roster with the claim note, not the worktree name

Reported by the session it happened to: its worktree is named
inter-session-communication-*, auto-generated at creation from a task that
session has never worked on -- it has been doing ASVS scorecard work for its
entire life. The directory name is the most visible identifier in presence.ps1,
overlap.ps1 and this hook's output, and it had already misled TWO sessions
(including this one) into guessing that session was building the announce hook.

A worktree name is a creation-time label, not a statement of current work, and
nothing keeps the two in sync. The claim note is the only field written
DELIBERATELY to say what a session is doing, so the roster now prints it, and the
legend tells the reader to prefer it over the name.

Joined on the claim's `worktree` path, normalised the same way as every other cwd
key here. Fail-open throughout: no claims directory, an unreadable claim, or a
peer with no claim all just mean the name is the only thing we have -- which is
exactly the status quo, never an error.

Same session also flagged that the branch I read for it from list_sessions was
stale (a spent, merged branch). The announce text already refuses to join on
branch and says why; this is a second, independent reason not to trust it.

* docs(coord): name the silent-control defect class in the drift inventory

A control that cannot distinguish 'ran and resolved' from 'ran and found nothing'
is not installed, however it looks. The announce shim outlived every other
silent-control defect found the same day BECAUSE it printed a status message --
which is more convincing than silence.

The structural cause is the reusable part: every receipt that hook would have
written lived inside the script the shim failed to find, so every check sat
strictly downstream of the failure it existed to detect. Looking was not
neglected, it was impossible. The question to ask of a new control is which
surface still reports when the control itself fails to load.

Formulation owed to a peer session that hit four instances of this class in one
day and named it more sharply than I had.

* docs(coord): record the broadcast constraints six sessions learned the hard way

Announce-on-join introduces a session; it does not let an established one push an
operational notice. That increment is deferred, and on 2026-08-01 six sessions
rehearsed it by hand for four hours. Three constraints fell out, recorded so the
next attempt does not rediscover them:

- A broadcast needs an EXPIRY or a predicate the RECIPIENT can evaluate, never a
  promise from the sender. A merge freeze shipped with 'lift when #119 merges';
  #119 died on an unrelated CI timeout, so five sessions held on a condition that
  could not arrive and a second round was needed to retract it.
- 'Don't do X' is the wrong primitive when automation already has X armed. The
  freeze asked for restraint while six PRs had auto-merge ARMED and would have
  landed with nobody clicking anything. The right ask was an action: disarm.
- Coordination a tool cannot read does not count. Two sessions agreed IN WRITING
  to hand over a file and the gate still refused, because the agreement was prose
  and the gate reads git.

Field data from the sessions that lived it, not speculation.

* test(coord): pin overlap's dirty-vs-committed signals against real git

Nothing drove overlap.ps1's row computation against a real repository, so the
question "does MatchedDirty hold when a file is dirty AND committed at once" was
unanswerable by the suite. Raised by the session that spent an evening in exactly
that state.

THAT CASE IS THE ONE THAT FAILS SILENT, which is why it gets a real fixture
rather than a stub row. A peer with uncommitted edits in one region and landed
work in another is a genuine collision. Had MatchedDirty been derived from the
committed diff instead of the working tree it would read FALSE there, the gate
would allow, and two sessions would write one file with nothing reported. The
over-block this replaced was loud and annoying; that would be quiet and cost
someone their work.

Verified the tests can SEE it rather than assuming: sabotaged the row to publish
an empty Dirty set -- the precise mis-implementation warned about -- and both
MatchedDirty assertions went red; restored, all five green. A test written after
the code, never observed failing, is a test of nothing.

Also pins that overlap does not rewrite a peer worktree's git index, by comparing
the index mtime across two queries. An observer must not perturb what it
observes, and this one was doing so on every PreToolUse before f55d6c6.

Stub rows would only have asserted that the plumbing carries a value someone else
computed; the whole question here is what git actually reports.

* test(coord): assert a wired coordination hook resolves to a script that exists

Raised by the session that traced the shim: the coordination hooks are not
installed copies, they are inline commands that locate their script in a working
tree at every invocation. If neither base yields the file, Test-Path fails, the
loop ends, nothing runs, and the tool call proceeds with no hook and no signal.
"The hook is uninstalled" and "the hook ran and permitted this" are
indistinguishable from outside, and nothing was watching.

Not hypothetical: a foreign UserPromptSubmit entry sat in this same settings file
for weeks probing a script that exists only in another repo.

The risk composes badly for collision_gate.ps1 specifically, which now (a) fails
OPEN on any error, (b) denies less by design after the dirty-vs-committed split,
and (c) silently no-ops when unresolvable. Individually defensible; together the
realistic bad day is "the gate was never running and nobody noticed". This closes
(c) -- the observation is not mine, and it is a good one.

Found immediately on writing it: FIVE user settings files across account
directories, not the one I knew about. The informational test also prints the
original defect as output rather than leaving it invisible:
  FOREIGN UserPromptSubmit [mefor-web-announce] -> scripts/hooks/announce.ps1:
  RESOLVES NOTHING HERE
It is another repo's entry, so this reports it and does not touch it.

Carries a NEGATIVE CONTROL, because the assertion passed on the first run and a
green that has never been shown to fail is not evidence. The real hooks cannot be
unwired to prove the predicate works -- the primary checkout is shared with live
sessions -- so it is exercised against a path known not to exist.

Local-machine only: CI has no user settings and these skip there, which means CI
does NOT guard this property. Said plainly, and every test prints what it scanned
BEFORE it can skip, per test_gate_installed_parity.py -- the pytest config has no
-rs, so a skip would otherwise render as a bare dot with no reason.

* docs(adr): ADR 0158 silent controls, plus a session handoff

Session ended on an owner stop-work instruction at 96% weekly account usage, so
this lands the two things that would otherwise have existed only in a transcript.

ADR 0158 records a defect class that recurred at least a dozen times across
independent surfaces in one working day, in at least two sub-classes: a bound
stated independently of the thing it bounds, and a control that cannot observe or
act on its own failure. Its spine is that a signal carrying too little
information to act on makes every reader re-derive significance by hand until one
of them derives it wrong -- so a correct-but-useless RED costs what a silent green
costs.

EVERY FIGURE IN IT WAS RE-DERIVED BY SOMEONE WHO DID NOT PRODUCE IT, against the
repository and the GitHub API. That pass refuted six claims, including four CI
numbers that were already merged, and including corrections this session had
itself issued hours earlier. Seven retractions are recorded INSIDE the document,
each carrying a found-by tag -- because the central empirical finding is that no
retraction was made by the author of the claim it retracts, and that is invisible
if attribution is smoothed into one voice.

Shape over detection is reported as a ratio rather than flattered: three fixes are
covered by tests in required CI legs, two by tests that always skip in CI, one by
a workflow change with a live residual, and the rest are corrected prose or still
open. The Decision separates ENFORCED rules, each naming its gate, from CONVENTION
that is knowingly re-breakable.

The handoff records what is pushed, what is filed-not-built, and the traps -- a
linked worktree's .git being a FILE, a Windows Python unable to read MSYS paths, a
raw hasher giving a false mismatch against a git blob on CRLF, and claim.ps1
silently discarding a note refresh. Each is stated as a fact plus its measurement.

It also records, first, the five claims this session got wrong -- including
retracting a CORRECT estimate on the strength of an incorrect measurement, and
sending that false claim to four sessions and the correction to only three.

One more arrived while committing this: the leak gate rejected the handoff for a
branch slug, on a line a standalone run of the same scanner had passed. The hook
scans STAGED files; the standalone run scanned tracked ones. Two scopes, one tool,
and only the fail-closed gate could see it. Recorded in the handoff.

No engine behaviour changes.

* docs(adr): land ADR 0158 -- silent controls, green signals that mean nothing

ADR 0158 was authored and committed in 994bfb1 on
claude/intersession-communication-hooks-a52335, a trailing commit pushed about an
hour and a half AFTER that branch's PR (#133) had already squash-merged. It
therefore never reached main and no PR carried it, while the coordination ledger
had already allocated the number: docs/adr/README.md stopped at 0156 and 0158 was
taken, so the index pointed at a document that did not exist.

That gap had a cost. At least four sessions cited this silent-controls taxonomy as
"ADR 0157" -- an unrelated HA demotion-safety document allocated to another
worktree and still in flight on PR #139. The document that settles the citation
was the one sitting unmerged.

This branch is cut from 994bfb1 itself, so the original commit stays in history
and authorship is exact. The prose, voice and ASCII-only convention are its
author's. This commit drops the session handoff and makes three factual
corrections where main moved underneath the branch after it was written, each
tagged inline in the ADR's own update convention rather than silently rewritten:

  * 0fdc326 is unreachable from main (this repo squash-merges). It is now given
    as "merged as 851c849 (#130)", matching the mapping the ADR already uses for
    7ebb2ff/2a6649fb.
  * transports/email.py and transports/direct.py were cited as carrying the same
    bare starttls() call. 093db33 (#132) gave both an explicit verifying context;
    pipeline/alert_sinks.py:384 is now the only remaining instance.
  * The "the false sentence is still there" claim (five sites, one of them
    numbered Decision rule 13) is closed out: on main the clause survives only
    inside its own CORRECTED block at :5270 and as a quotation at :7476. The
    interval is recorded; the rule it produced is unchanged.

HANDOFF-announce-hook.md from 994bfb1 is deliberately not landed: it is session
state rather than project documentation, no root HANDOFF-*.md has ever existed on
main, and it would publish local shim mechanics into a public repo. It stays on
its own branch.

Verified: exactly one commit in the repository ever added a 0158 ADR and exactly
one 0158 filename exists across all refs, so nothing competes for the number. The
index row is unchanged from 994bfb1 and appears exactly once.

No engine behaviour changes.

* docs(adr): make the 0158 TLS update non-perishable

The correction I added said 093db33 (#132) left alert_sinks.py as "the only
remaining instance on main". That is a checklist-shaped claim with an expiry
date: BACKLOG #323 layer 3 (PR #142) closes the alerts call site, and the
sentence goes false the moment it lands. Dating the observation does not help a
reader who greps for it in a month and finds nothing.

Restated as what happened rather than what is currently true -- #132 closed the
two connectors, the alerts call site is tracked as #323 layer 3 -- so it holds
whether or not #142 merges, and it says outright that the current state must be
grepped rather than cited from here.

Deliberately does NOT assert that #142 closed the cell: #142 is open at time of
writing, and asserting a merge that has not happened is the same defect pointing
the other way.

found by: the repo-security-review session, which owns #142 and re-derived all
three call sites against origin/main before raising it.

* docs(adr-0158): replace rotting line-number citations with greppable strings

The document's own rule, applied to itself: a quoted string survives a file
edit, a line number does not. Ten citations replaced.

WHY NOW. All three ci.yml citations (:229, :233, :254) resolve to unrelated
text the moment #138 lands, and six docs/BACKLOG.md citations had ALREADY
rotted on main before that -- +14 to +40 lines of drift from #345/#346/#347
being appended, with every cited claim surviving verbatim at a new address.
Measured fresh against origin/main and against #138's branch, not reused from
the report that found them.

TENSE, not just addresses. Two of the quoted strings do not survive #138 --
"Measured over the 11 PASSING windows-2025 runs" and "1.46x" are both deleted
by it, because #138 ADOPTS this ADR's retractions 1-3 wholesale (12:31, 21:34,
25:51, 1.006x, 1.206x, pools 42/39/36). Left in the present tense those two
sentences would ship knowingly false the hour #138 merges, so they now say
what ci.yml stated when this was written. The retractions themselves are
unchanged and are vindicated by #138, not contradicted.

ANCHORS ARE SINGLE-LINE ON PURPOSE. A first pass rewrapped two quotes across a
newline, which makes them ungreppable and would have swapped one rot for
another. Every anchor is now verified to grep as one line AND to resolve in
the tree it points at -- "ZERO tests failing" resolves in ci.yml both on main
and after #138.

pyproject.toml:266 was simply wrong: the zizmor pin is at :271, in the group
opening at :268. Replaced with the group name, which is what the sentence
needed and cannot rot. The residual it reports -- that the pin's home is
outside zizmor's paths filter -- is verified TRUE and unchanged.

OUT OF SCOPE, deliberately: line numbers into less volatile files remain
(test_stage_dispatcher.py, claim.ps1, zizmor.yml, install-coordination.ps1,
freethread-smoke.yml, collision_gate.ps1). So the ADR does not yet "state no
line numbers" outright -- see the handoff note.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…n adjacent question (#146)

The three rules in Secure_Development_Standards §3 catch prose that is TRUE and
misleading. They do not catch the failure that produced eleven retractions across
four parallel sessions on 2026-08-02: a claim that is FALSE when written while
feeling measured, because the instrument answered a question adjacent to the one
asked.

The eleven, each verified by the session that made it:

  git diff on a STAGED file      "unstaged delta?"        vs "is the tree dirty?"
  merge-base --is-ancestor       "is this an ancestor?"   vs "did this land?"  <-- squash-merge: always no
  a hash INEQUALITY              "are these different?"   vs "is the copy WORSE?"
  session-start banner           "who was live then?"     vs "who is live now?"
  grep -c $'\r$' on git diff     "does the diff render CR?" vs "does the FILE have CRLF?"
  $? after `cmd | tail`          "did tail succeed?"      vs "did the gate pass?"
  Actions ?filter=latest         "latest attempt?"        vs "what did the suite ever do?"
  JOB conclusion                 "did the job pass?"      vs "did the STEP pass?"

Two findings that make it actionable rather than a scolding:

  - Re-reading caught NONE of the eleven. A check that could fail caught one
    immediately. Re-reading confirms what you meant; it cannot test what you wrote.
  - None was a stale fact. Every one was wrong at birth. "#119 never merged (it
    died on a CI timeout)" was never true at any instant -- that PR's timeline
    carries exactly one `closed` event, simultaneous with `merged`. So dating a
    claim does not protect against this class; only re-deriving it does.

Hence the rule is a PROPHYLACTIC, checkable before the sentence exists and without
a peer: name the question, name what the instrument returns, confirm they are the
same sentence.

Also adds the one-liner to CLAUDE.md §11 alongside the other three, per the
provenance note's own reasoning -- an instruction that short cannot drift, and a
pointer nobody follows mid-task changes no behaviour.

No version-history row: the "Reviewing security prose" subsection carries none
(added in 39990f8 without one), so additions there set no bump precedent. No
change to the SSDF / ASVS / HIPAA mappings.

Named by the repo-security-review session, which applied it to its own four
retractions and found four for four; instances contributed by the
ci-margin-correction, announce-hook, sandbox-codec and ADR 0154 sessions.
wshallwshall added a commit that referenced this pull request Aug 7, 2026
…on main (#278)

* backlog: file #1096, the windows-2025 Tests (pytest) cap is exceeded on main

ci.yml sets step_timeout 36 for both Windows legs and, in the same comment
block, names its own re-derivation trigger: re-derive if a windows-2025
Tests (pytest) step is ever seen above 28:00.

Measured 2026-08-07 over all 80 ci.yml runs created that day, jobs fetched
with filter=all so an attempt killed at the cap is not hidden behind its
passing re-run, timing the STEP and filtering on step conclusion:

  windows-2025 Tests (pytest)   max passing 35:13   n=65 steps / 43 success
  cap 36:00 -> 47 seconds of margin, 1.022x
  passing steps above the 28:00 trigger: 43 of 43
  killed at 36:0x: 5, two of them push runs on main (7ecff8a, b78214f)

So the trigger is not merely fired, it is universally exceeded, and the cap
is failing green suites. ci.yml already records this shape once at 26:00
(PR #119, killed at 26:07 with zero tests failing, diagnosed there as
coin-flipping against the cap) and already admits 36:00 failed its own
spread rule when it was chosen, needing roughly 37:00 to hold.

Measured the step rather than the job deliberately: ci.yml records at least
three sessions substituting job durations while triaging this same cap, and
a job-duration reading of this pool gives 33-40 minutes, which invites the
wrong conclusion that the cap is comfortable.

job_timeout must be re-derived in the same act. The job carries two
step_timeout-gated pytest steps and its 46 is sized against the observed
sum, not 2 x step_timeout, so raising step_timeout alone moves the kill to
the job cap, where no step conclusion is reported and the instrument used
to measure this is lost.

Ledger mechanics, stated separately because they are independent claims:

  - Number allocated with scripts/coord/alloc.ps1, not grepped. Row added
    in this same commit at rank 6, ranks 6..108 renumbered to 7..109.
  - The renumber was bounded to the live table (lines 180..287). The file
    also holds a superseded 134-row 2026-07-10 table; an assertion checks
    it comes out byte-identical, and it does.
  - The four Distribution census lines were re-derived, not carried
    forward: Value 7 -> 8, Difficulty 3 -> 40, P1 -> 9, quick win -> 34,
    sum 108 -> 109.
  - The "Every one of the 102 open items" sentence was left ALONE on
    purpose. It is a dated claim about the 2026-08-03 re-scoring pass, not
    a live census, and bumping it would assert that pass scored items that
    did not exist yet. Prior passes appear to have bumped it; that looks
    like the actual source of its drift.

MY FILING IS CORRECT AND THE CENSUS IS STILL WRONG -- these are separate
claims and this commit only makes the first. Verified in both directions
with parse_items imported from backlog_status_check.py rather than a
hand-rolled scan, which surfaces PRE-EXISTING drift this commit does not
touch: 52 open items carry no row, 11 rows name an item that is not open,
and parse_items counts 150 open items against a census asserting 109. Those
two sets cancel on a total, which is why a matching sum never caught it.
That reconcile is its own pass and needs 52 items scored.

Gates run locally: backlog_status_check.py --min-items 150 OK (366 items,
each declaring exactly one status), ledger_check.py --ci OK.

* backlog #1096: reconcile with #1084, which is the same defect under a wrong instrument

#1084 was filed the same day, for the same leg and the same cap, and neither
item referenced the other. It is not a harmless duplicate: the two CONTRADICT
each other on the central action. #1084 says "Do NOT simply raise the cap";
#1096 says the cap must be re-derived. Left alone, whichever is worked first
leaves the survivor standing as an open item asserting the work was wrong.

The collision was invisible from this worktree. #1084 is not on main -- it
lives only on origin/claude/gate-3d-remedy-1057 (PR #261, open) -- so reading
docs/BACKLOG.md here shows no conflict, and backlog_status_check cannot see
one either. The cross-reference is therefore labelled FORWARD-LOOKING in the
item, per the rule that a citation which does not resolve yet must say so.

The contradiction resolves, and #1084 is not wrong in spirit -- its premise is
wrong. Its "~92% of step_timeout, about 17% headroom" comes from comparing JOB
wall time across three PRs (33m09s / 37m06s / 38m20s) against a STEP cap. On
the step the figure is 35:13 against 36:00 = 97.8% of budget, 2.2% headroom.
That is the same job-for-step substitution ci.yml already records at least
three earlier sessions making while triaging this very cap.

That changes what "protect the diagnostic" implies, which is the whole
disagreement. #1084 is right that step_timeout sits under job_timeout so a
deadlock below pytest surfaces as a step failure, and right that raising a cap
to buy a green tick would trade that away. But at 2.2% headroom, against a
distribution where EVERY passing run exceeds the file's own 28:00 re-derive
trigger, the cap fires on a healthy suite as readily as on a deadlock -- so
re-deriving it RESTORES the diagnostic rather than surrendering it.

Also absorbed from #1084 so closing it loses nothing: its three-wrong-answers
triage, the step-under-job rationale, its fix directions (margin reporting is
the cheapest and should likely happen regardless; splitting the Windows leg;
the measured 51% cut on tests/test_worktree_gate_remedy_families.py from
removing 30 of 36 git spawns), and its #1000 negative-control requirement.

Added stronger evidence than either item had alone: a SAME-COMMIT flip. Run
31192717684 (fix-1006-absence-mutation, head 27d7746) failed at 36:07 on
attempt 1 and passed at 33:14 on attempt 2. #1084 inferred a flip from three
DIFFERENT PRs, which is not identical content; this is.

The 43-of-43 figure is NOT rewritten to 45. An independent re-derivation later
the same day reproduced 35:13 to the second and the same five kills, with the
pool grown to n=68 and 45 of 45 above the trigger. It is recorded as a second
measurement with its own pool rather than folded in, because a number edited
away from the pool that produced it is exactly the rot ci.yml warns about.

Recommended disposition stated in the item: keep #1096, close #1084 as merged
into it, correct #1084's numbers rather than carrying them forward. There is
no allocation collision -- 1084 and 1096 are different numbers, only the
subject collided.

Gates: backlog_status_check.py --min-items 150 OK (366 items, each declaring
exactly one status), ledger_check.py --ci OK. Live table still 109 rows with
contiguous ranks; #1096 still parses open via parse_items.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant