0080 — Owed items: proof closes, a stall pages
- Status: Accepted 2026-11-01. Written while the slices are in flight, as ADR-0004 requires. Live on Smith once the Slack socket restarts onto the sweep slice.
- Date: 2026-11-01
- Deciders: Gera, 2026-11-01: "handled" needs proof and a stall pages. Anything an agent owes a person stays OPEN until it is proven done. A failure notice, an "expired", an "escalated (Slack only)" or a placeholder never closes it. When it stalls past its bound, it pages Gera once, and Fede too when it is his. He asked for this to apply beyond Smith's replies.
- Systems: smith-owed-asks (agent-harness
inbound_asks,owed_asks, and the newowed_items), plus the four places that record owed items: the closure transport (activities/slack_send),automation_run, the prompt responder's close, and the bridge handoff intake. - Relation: extends the reply rule (agent-harness #1791–#1793: only a real reply closes an ask; the owed-ask sweep runs every 5 minutes, re-runs an unanswered ask once 15 minutes after it arrived, and pages 15 minutes after that re-run if it is still unanswered (
owed_asks.RERUN_AFTER,PAGE_AFTER_RERUN)). ADR-0003 is respected: the page is a marked Slack post, and nothing calls into another system.
1. The decision
- One ledger, one chase. An owed item is a row in the
owed_itemstable, in the same file as the ask ledger (~/.claude/smith-state/inbound-asks.db). The row holds: kind, key, opened, due, owed-to, link, what happened, and the close proof. The 5-minute owed-ask sweep in the Slack socket also sweeps owed items. No kind gets its own checker, cron or channel. - Only proof closes. Each kind names what counts as proof. Proof is something outside the agent's own say-so: the PR merged, a decision row answered, the check ran green, a person's mute, a later DONE line for the same subject, or a person closing it by hand (
python -m agent_harness.owed_items close KEY --by NAME --proof TEXT). A FAILED, EXPIRED or "escalated" line opens or keeps an item. It never closes one. - A stall pages once. When an item is still open at its due time, the sweep posts ONE top-level message in #alerts (C0BGTNSP04C). It tags Gera, and Fede too when the item is owed to him. It links the record, or says in plain words why there is no link. It names the one thing blocking, when the kind can read it. The page is claimed in the row before it posts, as
owed_asks.claim_pagedoes, so racing sweeps and restarts never page twice. A post that fails gives the claim back. - Recorded at the boundary, not by each workflow. Every durable workflow's terminal line goes through one transport (
slack_send_activity, viaworkflows.closure.terminal_post). An EXPIRED terminal whose label is an owed kind opens its item there. A later DONE terminal for the same label and subject closes it. That one hook covers the PR drive, the prompt responder, tasks, operator tasks and decisions. No workflow code changes, so no Temporal replay risk.
2. The kinds and their bounds
The bound is how long a person can reasonably wait AFTER the system has said it stopped looking. The system has already spent its own budget (36 h for a drive, the responder's lifetime, 3 red nights), so each bound is short.
| Kind | Opened by | Proof that closes it | Pages when |
|---|---|---|---|
pr-drive-expired |
⏱ PR-drive · repo#N — EXPIRED (36 h lifetime or wake budget) |
the PR is merged or closed on GitHub, or a later PR-drive · repo#N — DONE |
still open 6 h after the EXPIRED line. The page names the one blocker GitHub reports: a hold label, changes requested, a red check, conflicts, or a draft |
prompt-no-row |
⏱ Prompt · <session> — EXPIRED whose detail says N with no decision row |
a decision row for that session, raised after the escalation, now answered | still open 2 h after the EXPIRED line |
red-check |
automation_run's chronic alarm, the night a job's streak reaches 3 (failure_streaks.CHRONIC_AFTER_NIGHTS) |
the job runs green; or a person mutes its alert source (alert_ignore); or the job is retired (it is gone from the nightly queue while the queue still ran) |
at once (the 3 nights are the bound), unless an open fix PR exists for its Alert ID. Then it pages if the job is still red 24 h after that PR merges |
task-expired |
⏱ Task …, ⏱ Operator task … or ⏱ Decision … — EXPIRED |
a later DONE terminal for the same subject, or a person closes it | still open 24 h after the EXPIRED line |
handoff-claimed |
a seat accepts a bridge HANDOFF ("Smith takes it — handoff X is mine") | the claim is marked done with a link; or it is released to a NAMED taker, and that taker's own claim on the item exists (it accepted) | not done 72 h after the claim — superseded by the 2026-11-01 amendment below |
Handing on a claimed item. (Superseded by the 2026-11-01 amendment below.) A seat that holds a live claim cannot hand that item to another seat without a reason. The outgoing HANDOFF's why must say it is a release, and name the taker. The item stays owed by the claimer until the taker's claim lands. The flip-flop on 2026-10-31/11-01 (Smith took handoff 003-voice-sim-sunset-2026-10-31, then handed it to Trinity the next morning) is exactly this case.
Retired overnight tests close their items. The retire-overnight-tests lane (session b28682e0, per Fede) is removing some nightly jobs. A job gone from the nightly queue while the queue still ran is "retired", and that closes its red-check item. This is told apart from a worker outage (the queue did not run at all), which failure_streaks bridges and which keeps the item open.
3. The survey: every place with this shape
Searched agent-harness for EXPIRED, expired, Slack only, nobody is watching, lifetime reached, handed to, drive expired and owns it now. Counts are from the outbound ledger, 14 days to 2026-11-01.
| Where | What it says | Verdict |
|---|---|---|
workflows/pr_drive.py (EXPIRED, lines 2385, 2411, 3023) |
"still open after 36h — drive expired. The open-PR reconciler owns it now"; "used its N-turn budget" | owed, pr-drive-expired (29 in 14 days) |
workflows/prompt_responder.py (_finish, line 1316; the Slack-only warning, line 1061) |
"N escalated (N with no decision row — Slack only)"; "escalation stayed Slack-only" | owed, prompt-no-row (59 of 131 Prompt EXPIRED lines carry a no-row count) |
automation_run.py chronic alarm (failure_streaks.chronic_banner) |
"has now failed N nights in a row … needs an owner, or muted deliberately" | owed, red-check (Morpheus 41 nights, maintenance-eval 89+, spine-health 49) |
workflows/task_goal.py (922, 1214, 1392), workflows/operator_task.py (3230, 3596, 4486, 4500, 4558, 5304) |
"lifetime reached with the task …", "row is unfinished and nobody is watching it" | owed, task-expired (9 in 14 days) |
workflows/decision_block.py (924) |
a decision that expired with no answer | owed, task-expired |
bridge handoff intake (slack_socket BRIDGE HANDOFF, bridge_handoff.handoff) |
"Smith takes it — … is mine", then a HANDOFF of the same work | owed, handoff-claimed (rules and bounds: the 2026-11-01 amendment) |
pr_drive Deploy EXPIRED (20 in 14 days) |
the deploy watch stopped before the worker served the sha | not owed to a person. The deploy watcher's own alarm and red-main own it |
workflows/task_watch.py (339), workflows/eval_heal.py (lock_timeout), workflows/job_run.py (timeout) |
a watch or a machine job ran out of time | not owed to a person. A nightly job that keeps failing becomes red-check |
workflows/approval.py, closure.py docstrings ("nobody is watching") |
comments, not posts | nothing to record |
4. Why this shape
- Why one ledger. The reply rule already has a ledger, a sweep every 5 minutes, a once-only page claim and a page format. A second checker per system would mean four crons, four page formats and four ways to page twice. Gera asked for this rule, not four of them.
- Why the transport, not each workflow.
terminal_postis the only function that writes a terminal line, and the send activity is the only place that knows the line's Slack ts (the link a page needs). Recording there changes no workflow code. Adding an activity call insideterminal_postwould change every workflow's history and need a patch marker on each. - Why short bounds. Each kind starts after a machine has already given up. A person learning about it 36 h late is the failure. Waiting 6 h more is only reasonable because a person may be mid-review.
- Why "a later DONE" counts as proof. It is written by the same workflow family at the same boundary. A workflow re-armed by a person that then finishes is proof the item was handled.
5. Consequences
- New pages land in #alerts. Expected volume, from the 14-day counts: about 2 PR pages a day before the backlog clears, a few prompt pages, and one page per chronic check (the three long-red checks page once each on the first sweep, unless muted, owned or retired first).
- Stated gaps:
- Prompt escalations open their item at the responder's close, not at the escalation. The escalation happens inside a workflow, and recording it there needs an activity and a patch marker. The close is at the transport. A responder that never closes (its worker died) opens no item. The worker log line
escalation stayed Slack-onlystays the trail. - A handoff's item key is the HANDOFF's lane and item. An outgoing HANDOFF that re-describes the same work under different words is not matched to the claim.
- Proof reads are only as good as their source. A PR closed unmerged by a person counts as handled. That is deliberate: closing it is a person's decision.
- Prompt escalations open their item at the responder's close, not at the escalation. The escalation happens inside a workflow, and recording it there needs an activity and a patch marker. The close is at the transport. A responder that never closes (its worker died) opens no item. The worker log line
- Trinity's seat runs the same code once Fede's machine pulls main. Until then, only Smith's items are recorded.
6. Slices
| Part | What | Repo |
|---|---|---|
| 0 | this ADR, and the smith-owed-asks catalog entry extended |
agentflow-relay |
| 1 | the owed_items ledger, the kinds and bounds, the EXPIRED/DONE recorder at the transport, the owed/close CLI |
agent-harness |
| 2 | the red-check and prompt-no-row recorders and their proof reads |
agent-harness |
| 3 | the sweep: proof reads, one page per item, in the owed-ask tick (after #1793 lands) | agent-harness |
| 4 | handoff-claimed: the claim on acceptance, and the release rule on re-handing (re-sliced as 4, 4b, 4c by the 2026-11-01 amendment) |
agent-harness |
Amendment 2026-11-01 — handoffs: the accepter owns it, the origin thread hears, and customer items move in minutes
Decider: Gera, 2026-11-01, on two cases the same day:
- 15:53. Smith handed a Western Slope lead alert to Trinity (#alerts thread 1793569884.602059, bridge thread 1793569987.882029). She accepted on the bridge in 3 minutes, then relayed it to a local session ("Clara Refactor") that had not read it. Seven hours later nothing was done, and the #alerts thread Gera reads still said only "Handed to Trinity".
- 21:15. The Barcelona held first email went the same way. It was relayed to "session 004" and left unread.
This amendment replaces the handoff-claimed row of §2 and the "Handing on a claimed item" paragraph.
The seat that ACCEPTS a handoff owns it, even if it relays the work to a session. A relay is not a release. The owed item is opened at intake on the receiving seat (
slack_socket._handle_sibling), keyed onbridge_claims.claim_key(lane, item)and owed to the sending seat's human. Only two things close it:- the claim marked done with a link;
- a release to a named SEAT whose own claim on the item exists.
A session the accepting seat hands the work to is never a taker.
The origin thread hears the acceptance and the outcome. Each gets one plain line mirrored back into the ORIGINAL thread the alert was in, which is where the human looks. The line comes from a mechanical emitter the
slack_postone-voice rule allows (a machine line, not a second brain voice). For example: "Trinity accepted this at 15:56" and "Trinity finished it:". "Handed to Trinity" is not the last word in a thread any more. Bounds, by who is waiting:
item first bound then customer-facing (a prospect, resident or lead is waiting) 30 min to proof of done; then Smith pings the accepter on the bridge, once 30 min more, then one page to Gera AND Fede internal 4 h one page to Gera (and Fede when it is his) These replace the 72 h bound. Whether an item is customer-facing comes from the HANDOFF's lane and item: a lead, prospect, resident or tenant word, or a customer alert source. An unknown item counts as customer-facing, so it fails toward the faster page.
A handoff still needs a reason beyond "their area" (
bridge_handoff.ownership_only, part 4). A code bug the alert engine found is the finding seat's to fix.
Slices:
| Part | What |
|---|---|
| 4 | the intake owed item, and the reason rule (agent-harness #1807) |
| 4b | the reader: done or seat-release proof, and a relay is never proof; the bridge ping at the first bound and the page at the second; customer and internal bounds |
| 4c | the acceptance and outcome lines in the origin thread |
Clarifications (2026-11-02, from the amendment's review):
- Release. A re-hand still needs a reason beyond whose area it is. Naming the taker in the
whyis no longer what proves the release. What proves it is the taker SEAT's own claim on the item (bridge_claims). Without that claim the accepter still owns the item. - Timer origin. Both bounds count from the ACCEPTANCE, meaning when the owed item opened at intake on the receiving seat, not from the original alert. (Superseded by "Where the item lives" below: the timer runs from the sender's item opening, at the HANDOFF post.)
- Unknown means customer-facing. That includes a classifier that errors or cannot read the lane: a failed read is "unknown", never "internal".
- Once only. The bridge ping is claimed in the item's row before it posts, exactly as the page is, so a restart can never ping twice. The page to Gera and Fede is ONE post that tags both.
- The mirrored lines carry a link, never customer details. The outcome line in the origin thread is "
finished it: ". It never quotes a lead's, prospect's or resident's details. - The one-voice rule. The mirrored lines are machine lines through the closure transport's internal send (
workflows.closure/slack_send_activity,internal=True), the same class as every terminal line. They are not a brain reply, so they do not count as a second voice in the thread. Part 4c links the agent-harness code it uses.
Where the item lives (2026-11-02, part 4b, agent-harness #1820). "The accepter owns it" is the rule about responsibility. The record of it is kept by the SENDING seat:
- Why the sender. The taker's BRIDGE CLAIM posts are mirrored onto the sender's claims board, and nothing writes the taker's done onto the taker's own board. So only the sender can read the proof. The sender's item (
taker=<seat>) is the one that nudges and pages. - The receiver's item. The one opened at intake on the receiving seat waits.
- Hand-on and hand-back. Handing a received item on closes it as "handed on". A handoff handed back closes the sender's item as "handed back", and the item is then owed by the seat that holds it again.
- Only the taker's done is proof, and only in this round. "This round" means the row's fingerprint (state, claimed-at, until) differs from the snapshot taken when the item opened. The board keeps one row per item across rounds, so last week's done never closes this week's handoff.
- Timer origin. The timer runs from when the sender's item opened, which is when the HANDOFF was posted. A handoff Trinity sends to Smith is tracked by Trinity's seat once it runs this code.
- The nudge's addressee is unchanged. It still goes to the accepter (the taker seat's bot, on the bridge). Only the record moved to the sender.
- No mirrored done is unknown, not done. If the bridge mirror is down, the sender never sees a done, the item stays open, and the nudge and page fire on schedule. The fingerprint and snapshot are defined in agent-harness
bridge_claims.fingerprint/snapshot(#1820), the near copy.
Amendment 2026-11-02: a session whose link is still to come (session-link)
Gera approved this on 2026-11-02. Smith posted "I started a session … it hasn't printed its link yet … I'll post the link once it prints" four times in two days. It never followed up, even when the session ran and opened a PR (propflowai #24620 from lambda-version-prune). The launcher's lookup was walking the same transcript tree 525 times, so the link never printed in time. local-bin#736 fixes that. This amendment adds the follow-through as one more kind on this ledger, not a second tracker.
| Kind | Opened by | Proof that closes it | Bound |
|---|---|---|---|
session-link |
Smith's delivered post says it started a session AND that its link is not there yet (outbound_ledger.record → session_link_owed.note_post), keyed on the slug of the turn's own launcher call |
a later Smith message in that thread carrying the session's id or its PR; or Smith's own one line in the thread: the session link once its id is readable, or, at the bound, the honest line that it never started and what was checked (tmux stamp, task file, recorded PR) | 30 min |
Why 30 minutes. A Driver takes its kickoff 7 to 47 seconds after its tmux is created (measured on the 33 live Drivers, 2026-11-02), and the fixed launcher prints its link within about a minute. Thirty minutes with no id to read means it never started. A person waiting in the thread should hear that from Smith in the thread, not as a page.
How this kind differs from the other rows. Its normal ending is a line in the asking thread, not a page in #alerts. Each line is claimed before it is posted, under the same rule as the handoff acceptance lines. A line that fails to post is not proof. The item stays open, and an item past its bound with no line pages like any other. Its proof is re-read every 5 minutes instead of hourly, so the link is posted the tick after it becomes readable.
Slice: agent-harness #1854.
Amendment 2026-11-04: a stall that is Gera's alone pages his DM
This amends where §1.3's page posts, for one class of item. Everything else in §1.3 holds: one page per item, claimed before it posts, a link or a plain reason for none, the one blocker named. In #alerts the page still tags Gera, and Fede too when it is his; a page in Gera's DM tags nobody, since a DM needs no mention.
Why. Fede asked for a retro on #alerts noise on 2026-11-04 (dock alerts-noise-retro). In the outbound ledger from 2026-11-03T08:00Z to 2026-11-04T14:00Z, 11 of the 41 top-level #alerts posts were owed-item pages. 5 of the 11 were prompts with no decision row, owed to nobody but Gera. Gera ruled on 2026-10-14 that per-event machine pages go to his DM, not the team channels, and the gera-dm purpose line in propflowai config/channels.toml says the same.
The rule (agent-harness owed_chase.page_channel):
- A stall that is Gera's alone goes to Gera's DM (the
gera-dmkey) with no @-mention, because a DM needs none. "Gera's alone" means the read did not setfedeandowed_todoes not name Fede. Examples are a prompt with no decision row, an expired drive or task, or an internal handoff. - A stall that is Fede's too keeps #alerts and tags both, as before. Examples are a customer-facing handoff (
customer=1), a PR Fede wrote, or an itemowed_toFede, such as his seat's unparsed handoffs. Fede is never DM'd (his 2026-10-06 rule), so a channel tag is the only way he hears about a stall that is his. Moving those pages would delete his signal, not relocate it. This was decided byfable-decideff42dcdac; the lane brief had proposed moving them too, untagged. - A seat other than Smith keeps #alerts for every page.
gera-dmis Gera's conversation with the Smith bot, and no other seat's bot can post into it.
Slice: agent-harness #1921.