ADR 0182 — The handoff offer gains a lifecycle: one pending per destination, and superseded
Status
Accepted. 2026-09-30 (AT-291 and AT-292, story HS-075, round 29; the vault's
AT-273 merges into AT-292 by owner decision). On top of
ADR 0038 (an external handoff addresses only an
area lead or an agent without an area, validated in CreateHandoffUseCase) and
ADR 0090 (AppSec delivers the
threat model to three destinations). It does not edit them: it adds a state
and an invariant to the handoffs table.
Context
Measured in the dev code on 2026-09-30:
HandoffStatusdeclaredoffered | accepted | completed | rejected, but onlyofferedandacceptedwere written. There was no expiration, supersession nor dedupe.CreateHandoffUseCaseinserted a new row on every call. Any repetition became a new offer: the agent repeatingoffer_handoff, the Criativo reconfirming readiness, the Staff returning another RFC.OfferInfraHandoffUseCasewas not idempotent. A double click on "architecture ready", or two tabs, recorded the milestone twice and asked the engine for two Arquiteto closing turns and two offers to the Dev Lead — 2× Infra and 2× Dev Lead on screen.- AppSec (
criar_handoffs_appsec/3) creates three offers per story withrun_design, to the same three destinations (arquiteto,dev-lead,infra). With N stories, 3N pending offers piled up. - An offer to an agent that was already active stayed
offeredforever, and the screen showed it as actionable: accepting would activate again someone who was already up.
Decision
-
New status
superseded(migration0064, onlyALTER TYPE … ADD VALUE). The offer that stopped being the current one changes status in the ROW, as acceptance already did, and gains ahandoff.supersededevent withhandoffId,toAgent,motivo(agente_ativado|nova_oferta) andsubstitutaId. Never an UPDATE on an event: thehandoff.offeredthat announced the offer stays as it was. The event lands in the OLD offer's session, with thesystemactorhandoff-lifecycle, and goes in even in a closed session (it is not conversation, RN-581). -
At most ONE
offeredoffer per (project, destination). The decision is pure (decidirOferta,domain/sessions/ciclo-de-vida-do-handoff.ts):- no pending one, create;
- pending in the SAME session and the new offer brings no new artifact
(
nullor equal) → return the existing one (desfecho: ja_oferecido), with no row nor event. It is the double click, the two tabs, the agent repeating the tool; - any other case (NEW artifact, or pending in another session) → create the
new one and SUPERSEDE the pending one (
desfecho: substituiu_oferta).
Why supersede, and not always return the existing one. The card asked for both options and preferred not to lose the new artifact. Returning the old one when the artifact changed would leave the rewritten brief (or the Staff's new RFC) without an offer carrying it. Returning a pending one from ANOTHER session would leave the requester with nothing to accept in the conversation they are in — and, if the other session already closed, with nothing acceptable anywhere (RN-581 refuses acceptance in a closed session). Returning only holds where nothing is lost: same session, same artifact.
-
Concurrency via a TRANSACTION advisory lock (
pg_advisory_xact_lock(hashtextextended('handoff:<projeto>:<destino>'))), not via a partial unique index. The index would require cleaning the duplicates that already exist BEFORE creating it, in the same migration — and the new enum value cannot be used in the transaction that creates it. Cleaning by markingrejectedwould lie; cleaning without an event would violate the rule itself. With the lock, the earlier duplicates converge on the next offer or activation for that destination, WITH an event. The lock is per destination, not per project: offers to different destinations do not wait on each other. -
An offer to an agent already ACTIVE in the project is refused, 409
agente_ja_ativo, with no row nor event. Active =agent.activatedfor it in a project session that is NOT terminal — the screen's reading (session-handoffs.ts) extended from session to project; a closed session stops its conversational agents. Themessageis the text the model reads (RN-163): theoffer_handofftool passes it on literally, andFalhaDeTurno.origem/1classifies it aspolitica, notcodigo. -
Activating an agent supersedes the pending offers to it, in ANY session of the project, through the three paths that activate: acceptance (which goes through
ActivateAgentUseCase; the accepted one is alreadyacceptedand is not touched), direct activation and execution activation (bydev-<modulo>). -
OfferInfraHandoffUseCaseidempotent. Each destination (infra,dev-lead) with a pending offer or already active is not triggered again; with both like that, nothing is written nor asked of the engine (desfecho: ja_oferecido,jaAtendidossaying why). Only what is missing is triggered: Infra active and Dev Lead without an offer asks only for the Dev Lead's, with no Arquiteto turn. -
AppSec only offers to whoever has not yet received one nor is active (RN-636). The engine asks the api in
seAusentemode (create_handoff_if_absent/5), instead of checking beforehand: asking from here would race with another offer, and the api answers under the destination's lock. "Already offered" and "already active" do not becomeagent.error; a real failure is still narrated per target (RN-116).
Consequences
- The screen gets one more status.
GET .../handoffsreturnssuperseded, and the creation routes returndesfecho. The web types are regenerated here; what the screen DOES with the new status (not offering it as actionable, reading "active" from the project) belongs to thehandoff-weblane (AT-293..295). - The race of two clicks at the SAME instant on "architecture ready" still
records the
architecture.readiness_confirmedmilestone twice. No offer is duplicated (the lock), and the Arquiteto's second turn is refused by the engine with 409turno_em_andamento(ADR 0163). Also closing the event would require holding the lock during the engine call, which is network inside a transaction — refused. - A new offer to an active agent ceases to exist, and with it "reactivating through the offer". Whoever rewrites the brief with the PO already active gets the named refusal; the artifact stays in the log and the conversation goes on with the active agent. Whoever needs a new round closes the session in which it is active.
- The threat model offer of a second story is not born while the first one's is pending (RN-636). The artifact stays in the log; the destination, when activated, reads the project's threat models. The pending offer does not point to the most recent one — that is the price of not superseding the offer that still awaits acceptance.
- Execution activation supersedes offers to
dev-<modulo>, which today are not born (a subagent does not receive an external handoff, ADR 0038). The call exists so the rule holds the day the hierarchy changes, and costs one read per module. - The earlier duplicates are not cleaned by the migration. They converge on
the next offer or activation to the same destination. A destination that never
again receives an offer nor an activation keeps its
offeredones, as today.