| name | Lifecycle composition — the bead → PR → visit seam and how completion propagates back |
|---|---|
| description | How the pack's three lifecycles compose — the rule for recording a wait as a graph edge, who re-derives it, and how a finished piece of work propagates back to the conversation that asked for it. Read it when work crosses from one lifecycle into another, or when something finished without anyone noticing. |
The pack runs three lifecycles, and each already has exactly one owner:
| Flow | Owner |
|---|---|
| bead | state-machine.md |
| PR | refinery-merge-cadence.md |
| visit | gascity-human-engagement.md |
This document owns none of them. It owns the seam: the place where a conversation routes work out of a subject, that work becomes a bead, the bead becomes a PR, the PR lands — and the result has to find its way back to the subject that was waiting for it.
The seam is where the expensive failures live. Each of the three lifecycles runs correctly on its own; what fails is the handoff between them, and it fails the same way every time — a wait gets recorded once, in a form nothing can re-evaluate, and then nothing ever re-asks the question. Before this document there was nothing to correct when an instance was found, so each one was rediscovered from the code.
Mandate. The composition rule between the pack's lifecycles: how a wait created in one lifecycle is recorded so another can act on it, which side re-derives it, and how completion propagates back to the party that is waiting. It speaks authoritatively on that rule and on the failure class that appears when the rule is broken.
Boundaries. It does not restate the three lifecycles — each has one owner above, and this doc defers to them rather than summarizing them. It is not a fourth lifecycle narrative: it describes no state a bead, PR, or visit passes through. It does not carry the board's rendering rules or the converse sitting's procedure, which belong to gascity-human-engagement.md, nor the routing mechanics, which belong to gascity-routing-model.md. The general model of how a wait is represented, which is the hold primitive, the demand primitive and the shape law for routed work, is invariant I1 in component-model.md; the rule below is that model applied to this seam.
A wait is a graph edge, re-derived on every read. A conclusion is prose, stored once and never cleared. Never the reverse.
Three clauses, each of which has been learned the expensive way:
When a sitting routes work out of a subject and parks it, the sentence it writes — "holding, awaiting the fix" — is for a human to read. It is not the record of the wait. The record is an edge to the specific bead being waited on.
A sentence freezes at the moment it is written and is never true again. Two subjects, one genuinely blocked and one entirely finished, produce mechanically identical rows if the only difference between them is prose. The board cannot tell them apart, so it shows both as wanting nothing, forever.
The mechanism: gc-helm takeaway <bead> "<text>" --waiting-on <work-bead>,
repeatable, writes a blocks edge beside the takeaway string
(assets/scripts/gc-helm.sh, takeaway). The prose still gets written — it is
the durable record of what the sitting concluded, and it is never cleared. The
edge is what makes the wait machine-answerable.
doctor/check-wait-is-an-edge holds this clause where it is decidable. A bead
in any non-closed status that carries a hold marker must also carry a blocks
edge to a bead still live in the same store. Closed is the one status that ends
a hold, so every other one is in scope: a hold stated by a bead already
claimed, blocked or parked is as unanswerable as an open bead's. Only that edge
type is checked, because only that type holds the bead out of bd ready.
The markers are a declared list, [holds] in lifecycle/lifecycle.toml, and
the check never reads prose for a hold. That boundary is this section's rule
pointed the other way. The sentence a sitting writes is a conclusion, stored
once and never cleared, so a check that read sentences for holds would be
treating prose as the hold carrier again. The surface is also unbounded: no
list of wait verbs finishes, and every phrase one missed would report a clean
pass. A finding is graded by what the graph carries. A bead with no blocks
edge at all needs one filed. A bead whose every blocker has closed, or names
another store, has outlived its edge and needs its disposition instead.
Whoever needs to know "has this landed yet?" asks the question fresh, at the moment it needs the answer. It does not read a status field that something else was supposed to have updated.
This is the clause that carries the most weight, because it is what makes the
rule robust against the rest of the system being imperfect. The board derives
disposition_due per render — parked, with a non-empty waiting set, all of
whose members have closed (assets/scripts/gc-helm.sh, mirrored in
services/helm/internal/board/derive.go). Nothing is stored, so nothing has to
be cleared later, and no cleanup path can be missed.
The same edges answer the same question for the two human-gated kinds, and there
the answer points the other way: a decision or gc.routed_to=human row whose
takeaway is recorded and whose waits have all landed stands DOWN out of the
attention band rather than up out of the parked floor, reading "ruled — close or
extend" (tk-b3rga, Until it is answered in services/helm/README.md). One
bead can be gathered under both readings, so disposition_due yields to the
stand-down on a human-gated subject: the two would otherwise band the same bead
two ways and the louder row would win the dedup.
Concretely: tk-puh9d (open since 2026-08-02) is the defect that a bead's
stored blocked status never auto-clears when its dependencies close. The edge
is right there and nothing re-derives from it. The board is unaffected by that
bug — not because it was fixed, but because the board never reads the stored
status in the first place. A re-derived answer routes around a stale stored one.
The join point is the bead closing, not the PR merging. This is what makes
the PR lifecycle composable at all: the refinery is the single writer of merged
truth, and it closes a bead only after verifying the merge actually carried the
work (see state-machine.md, The merge condition). So
closed is the one signal that means landed and verified, and the waiting
side keys on it. A waiting party that watched
GitHub directly would be reading a different, earlier fact.
Fail quiet, in the direction of not acting. A blocker counts as landed only
on a positive closed. An id that cannot be answered for — a store in another
rig, an external: reference, a query that timed out — reads as still open
and the subject keeps waiting. This is deliberate and asymmetric: a missed
promotion costs a glance, while a false "this is finished, go dispose of it"
invites closing a subject whose work is still in flight.
Deriving the answer is not the same as delivering it. A board that turns a row
ELEVATED has computed the right thing and told nobody; it still depends on a
human opening the board and noticing. That is the operator's attention being
spent on discovery, which is the thing the pack exists to avoid.
So the terminal clause of the rule: when a subject's waiting set fully closes, something must file a visit back to the conversation, rather than only re-banding the row.
The intended shape is tk-2cyxo (in flight):
- A sweep, not a render side effect. Board renders stay read-only and idempotent; the actor cannot depend on anyone opening a board.
- Scoped to operator-origin subjects — the set where a human has a standing expectation of an answer.
- Nothing left to un-mute. A takeaway no longer suppresses the liveness
sweep's stall reporting (
assets/scripts/liveness-sweep.sh): the wait it names does, through the edge that records it, so a subject whose waiting set has fully closed already returns as an unnamed wait. What the push adds is where it returns to — the subject's own conversation rather than the batch queue. - Idempotent, never filing a second visit while one is open for the subject.
- Additive. The takeaway is never cleared; the visit is filed beside it.
- Self-describing. The visit states its own premise — the subject, which blockers closed, and when — so the next sitting can cheaply kill it if the situation has changed.
The seam has two shapes, and which one applies is decided when the sitting files the work — not later. Getting this wrong is the most common way a subject goes dark.
- Routed work filed as a
parent-childCHILD of the subject propagates by roll-up: the parent is banded by its open children rather than by the parked floor, because "wants nothing" is false while open work hangs under it. - Routed work filed as an independent bead propagates by the
blocksedge of clause 1 and thedisposition_duederivation of clause 2.
These are not interchangeable, and the reason is a hard constraint rather than a
convention: bd refuses a blocks edge from a parent to its own descendant
(recorded in assets/scripts/gc-helm.sh's kind-classification header). So work
filed as a child cannot be expressed as a waiting_on edge — the roll-up path
is the only one available to it. The canonical converse shape files routed work
as a child of the subject, which makes the roll-up path the common case and
--waiting-on the mechanism for everything else.
Both paths must be live for a subject to be visible. tk-a9k0l (landed
2026-08-23, PR #422) is what happens when one of them is broken: a parked row
hardcoded children: [], so a parked subject reported zero children and its
open children vanished from every surface — the roll-up path silently deleted.
One rule, discovered four times in four different surfaces. Listing them is the point: individually each reads as a local bug, and only together do they show a composition rule that was missing rather than four unrelated defects.
| Instance | Surface | Status |
|---|---|---|
tk-2plde |
A parked subject's wait was free text, so completion never propagated | Closed 2026-08-22 (PR #411) — the wait is now a blocks edge, re-derived per render |
tk-st143 |
The liveness sweep filed visits about already-landed work | Closed 2026-08-22 |
tk-t4rlv |
A doc tracker outlived its file by 20 days | Closed 2026-08-22, disposed to a successor |
tk-puh9d |
blocked status never auto-clears when deps close |
Open since 2026-08-02 — the one instance left |
The measured cost of a single miss: tk-yps55 sat parked for 29 hours after
the work it was waiting on had merged, and cost a full converse sitting to
discover it was already finished. In a second incident, an audit that had
overturned its own commissioning premise sat in a merged file for 4h19m before
the operator found it by eye — nothing in the system had noticed
(tk-16f29, the operator-origin bead reporting the seam failing on the very
audit that produced this document).
One caveat applies to every fix in this class: the shell board and
services/helm/ are separate implementations of the same derivation, so a fix
to one does not reach the other. Any change to shared derivation lands in both.
When a change makes one part of the system wait on another, it has to answer three questions, and a missing answer is the defect:
- What is the edge? If the wait exists only as prose, a status field, or a convention, it is not recorded.
- Who re-derives it, and when? If the answer is "whoever notices", nothing does. Prefer a derivation at the point of use over a stored flag that something else must remember to clear.
- Where does completion land? If the answer is "the row changes colour", the work is only half done. A push, not a state a human has to go looking at.