0024 — One thread, one pointer: the heartbeat's Slack table is retired and the ticker feeds the card

The failure

agent-smith #371 (merged 2026-09-04) cut the thread's phase surface over: the Slack message became one line and one /agents link (phase_table.render_pointer), and the eleven rows moved to the /agents/<sessionId> close-up, fed by the phases.json snapshot ADR-0007 specifies. The old render_live survives in agent-smith only behind not self._pointer_only, for pre-cutover Temporal replay.

The cutover missed a caller in another repo. ~/.claude/scripts/propflow-status/operator-heartbeat.py, run every 120s by the co.propflow.operator-heartbeat LaunchAgent, was the only remaining live caller of phase_table.render(...) — the full table. It posted and chat.update-edited the same eleven rows into the same Slack thread, from a message of its own.

Measured on ~/.claude/smith-state/outbound-ledger.db, 2026-09-07 → 2026-09-11:

-- ⚠️ NOTE THE BACKTICKS. The same pattern without them returns 0 and reads as
-- reassurance; that wrong-instrument zero was the first answer this
-- investigation got, and it was wrong.
select source, count(*), min(posted_at), max(posted_at) from posts
 where posted_at >= '2026-09-07' and text like '%| `plan` |%' group by 1;
-- operator-heartbeat | 18 | 2026-09-07T04:37:54+00:00 | 2026-09-11T20:08:00+00:00
-- ⚠️ AS OF 20:27Z 2026-09-11 — the last tick before the cutover. Re-run it TODAY
-- and it reads 22, max 2026-09-11T20:33:05Z. The four extra rows are the
-- cutover's own churn, not a broken claim: the reclaim deleted both frozen
-- tables at 20:28Z, a still-OLD live copy of the script re-posted them at
-- 20:30:38/20:30:42 and edited them at 20:33 (the retired code renders no
-- table at all, so those posts cannot have come from it), and the reclaim —
-- which gates on the CONDITION rather than its own migration flag — fired a
-- SECOND time and deleted them at 20:39:23Z, appending a second `deleted` to
-- `heartbeat.json.table_retired`. The zero-table window opens at 20:39:23Z.

18 posts across 7 distinct threads, as stamped above. On the kickoff thread of 2026-09-11 (C0BDW7G1Z62 / 1789157069.157999) the one-line pointer landed at 20:05:08 and the eleven-row table at 20:05:54, edited again at 20:08. A reader saw both — which is precisely the "two renderings of one truth, edited on different cadences" that phase_table.py's own header spends a page refusing.

This is the class ADR-0004 exists for: a system nobody catalogued, so nobody checked it. It was absent from SYSTEMS.md until this ADR's PR.

The decision

1. Slack carries the pointer. The card carries the rows. One message per thread.

operator-heartbeat.py no longer renders a table and no longer owns a Slack message. Per tick, per live task, it makes exactly one call into agent-smith's already-landed entry point:

thread_phase_activities.refresh_phases(channel, thread_ts)

Not a second mechanism — the mechanism. That call re-derives the rows once (phase_table.derive), rewrites phases.json, and publishes the pointer through thread_anchor.publish_table, the compare-and-write the cutover reclaims. Because it is the same anchor, a thread agent-smith has already rendered gets an edit of the existing message (observed: unchanged), and a thread nothing has rendered — a task started by start-operator --slack-thread, which writes only task.json.origin and which no reply path ever touches — gets its pointer minted. No thread ends up with two Smith messages, in either direction.

2. The ticker survives the table, because the card needed a ticker

This is the part that is not merely a deletion. Before this change phases.json was written only when a Smith workflow happened to render. An Operator-only task therefore ran with a stale card: measured 2026-09-11, arch-design-stress-test was active with a phases.json last written 2026-09-08 19:30. Once ADR-0007 makes the close-up the phase surface, a surface nobody refreshes is the 2026-08-20 incident wearing the new surface's clothes — which refresh_phases's own docstring predicts in as many words.

So the 120s tick keeps its job and changes what the job is: it feeds the card. Receipt, first run: arch-design-stress-testedited, its phases.json advancing from 2026-09-08T19:30 to 2026-09-11T15:27:56.

3. The task dir's slug is asserted, and a foreign binding is a decline

refresh_phases resolves the task from the thread's evidence row, not from the task dir the ticker walks. A thread Smith never rendered has no task_slug, so the log read returns nothing, every row is a gap, and decision D1 suppresses the post — the thread goes silent, which is the exact failure the heartbeat was built to prevent, arriving through a new door.

The ticker therefore asserts the binding it already knows via thread_phase.claim_task_slug — the purpose-built compare-and-write, not record(task_slug=…), which merges and would overwrite an incumbent and orphan the Driver already watching the thread. If the thread is bound to a different slug, the tick declines and logs whose it is. Filing one task's phases under another task's pointer is the one thing a single anchor must never do.

4. What was lost, named rather than implied

The table carried a footer headline the ticker computed each tick. It is gone from Slack; render_pointer does not render evidence.headline. Its homes:

carried now lives
live subagent activity, off the Driver's tmux pane the /agents/<sessionId> close-up streams the pane (SessionDetailClient.tsx, usePaneStream(row.tmux, row.paneId), transcript-tail fallback when a live session carries no pane stamp) — continuously, not every 120s
alive / dead the same page's roster row (endedOnRoster); acting on a dead Driver is ADR-0003's ladder, which is where it belonged
last events.jsonl milestone events.jsonl, which the relay already walks

⚠️ The named loss. The ⚠️ "operator tmux not found — it may have ended or been killed" line no longer appears in Slack. A human who only reads the thread is not told; they must open the link. That is the price of one surface, it is recorded here rather than discovered later, and re-enabling it is one render call in one LaunchAgent script.

5. The frozen tables were reclaimed, the dormant ones were not

A message the ticker owned and will never edit again freezes mid-sentence — "still on it :claude-dancing:" over eleven rows, forever, in a live thread. Worse than the table was. Two were live at cutover and both were deleted (slack.delete_message, hard-gated to Smith's own authorship, and it records the removal in the outbound ledger so the daily review sees a removal, not a gap).

Seven older heartbeat.json rows still carry a msg_ts. All seven are done and carry no Slack binding at all, so the ticker cannot reach them — and should not: _publish_pointer's standing rule is that a dormant thread keeps the last true thing it said, and back-filling Slack history nobody asked to change is not a tick's job.

Amendment to ADR-0007

ADR-0007's Consequences section currently reads:

Both surfaces render one derivation. Slack keeps its pinned table (#348 withholds it only when every row is a gap — decision D1 there; the render still happens, because workflows branch on states / done), and the board now shows the same rows without Slack being the only place they exist.

That clause is superseded as of 2026-09-04 (agent-smith #371) and this ADR. Slack keeps its pinned message — the anchor, the D1 gate and the render-always rule are all unchanged — but that message now carries one line and one /agents link, not the table. ADR-0007 is otherwise unaffected: its forcing argument for the snapshot transport is what makes deleting the Slack rows possible, and §3's "the canonical close-up is /agents/[sessionId]" becomes load-bearing rather than advisory, because it is now the only place the rows render.

The cutover itself (agent-smith #371, 2026-09-04) was never written down — it is the off-ledger decision this ADR also closes.

Constraints this must not break

Consequences