fix(office): keep an agent's session alive while it's still working the task (#3556)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-13 22:26:16 +08:00
committed by GitHub
parent 20308a2d16
commit 509a1bb9b5
25 changed files with 3245 additions and 63 deletions
@@ -0,0 +1,195 @@
---
status: draft
system: office
created: 2026-09-08
owners:
- kandev
---
# Office Seat Claim Decision Guard Requirements
## Overview
`AC-OFFICE-SEAT-PROVENANCE-002.5` requires that a decision already recorded
against an agent is never reattributed to a different agent by a later
registration. Two mechanisms defend it in the shipped code, and they defend
different windows.
The first is a filter applied while a registration is choosing which seat to
claim: a seat that already carries a decision is not selected. It runs inside
the registration's transaction and covers every decision that was committed
before the registration looked.
The second is a condition on the update that actually reassigns the seat,
requiring that no decision exists against it at the moment of the write. It
covers only the window *between* those two points - a decision that commits
after the registration chose the seat and before it reassigned it.
That window is now closed for every decision the shipped product records.
Decision recording serializes on the same per-`(task, role)` exclusion the
registration holds for its whole transaction, so a decision carrying a role
cannot commit inside another writer's window. The exclusion is acquired only
when the decision carries a role, and the decision store deliberately supports
recording one without: the only validation on that path requires a task, a
step, a participant and a verdict. A decision with no role skips the exclusion
entirely, and for that decision the condition on the update is the only thing
standing between a recorded verdict and its silent reattribution to whoever
registers next.
No shipped caller produces a decision without a role - both entry points
resolve one, or refuse - so this is a latent path, not a live defect. It is
worth stating because the guard defending it is currently documented as
something else. The concurrency test covering this behavior asserts that the
two writers lock on non-contending namespaces and that the condition on the
update is therefore load-bearing. The first half stopped being true when the
shared exclusion was introduced, and the second half is now true only on the
path the test does not exercise. A test that cannot fail is worse than no test,
because it is counted.
Office owns this contract: it owns the registration path, the decision store's
office-facing entry points, and the exclusion the two share.
## Terminology
- **Registration** - a request to seat a named agent profile in a participant
role on a task, which may claim an existing automatically-cast seat or write
a new one.
- **Claimable seat** - the sole automatically-cast, undecided seat in a role's
slate, which a registration may reassign to the registering agent.
- **Seat exclusion** - the per-`(task, role)` mutual exclusion that automatic
casting, registration and role-carrying decision recording all acquire, held
for the duration of the acquiring transaction.
- **Roleless decision** - a decision recorded without a participant role. The
decision store accepts one; it supersedes prior decisions by participant
rather than by decider and role, and does not acquire the seat exclusion.
- **Reattribution** - a seat carrying a recorded decision coming to name an
agent other than the one whose decision is on file, so that the verdict reads
as having been given by an agent that did not give it.
## Requirements
### REQ-OFFICE-SEAT-GUARD-001: A recorded decision survives a concurrent claim
**Intent:** `AC-OFFICE-SEAT-PROVENANCE-002.5` must hold on every path that can
record a decision, including the one that does not participate in the seat
exclusion. The shipped code already satisfies this requirement; it is written
down because nothing states it, nothing tests it, and the code comment
explaining the mechanism describes a different one.
#### Acceptance criteria
- **AC-OFFICE-SEAT-GUARD-001.1:** When a decision exists against a seat, a
registration shall not reassign that seat to another agent, whether or not
that decision carries a participant role.
- **AC-OFFICE-SEAT-GUARD-001.2:** When a roleless decision against a seat
commits after a registration has selected that seat as claimable and before
that registration reassigns it, the registration shall reassign no seat and
shall write a new seat for the registering agent instead.
- **AC-OFFICE-SEAT-GUARD-001.3:** The behavior required by
`AC-OFFICE-SEAT-GUARD-001.2` shall not depend on the decision writer and the
registration serializing on the seat exclusion, because the roleless decision
path does not acquire it. An implementation that removes the condition on the
reassigning update and relies on the exclusion alone does not satisfy this
requirement.
- **AC-OFFICE-SEAT-GUARD-001.4:** When a registration declines to reassign a
seat under `AC-OFFICE-SEAT-GUARD-001.2`, it shall leave that seat's agent
profile, provenance, decision-required flag, position and creation time
unchanged, and shall leave the decision itself unchanged.
- **AC-OFFICE-SEAT-GUARD-001.5:** A registration that declines to reassign
under `AC-OFFICE-SEAT-GUARD-001.2` shall report the same outcome, and produce
the same effects, as a registration that found no claimable seat at all: a
new seat is written, and none of the effects a claim earns - the claim
activity record, the displaced agent's session termination, the displaced
agent's queued run cancellation - occurs. Nothing was displaced, so nothing
shall be reported as displaced.
- **AC-OFFICE-SEAT-GUARD-001.6:** A registration shall not be failed by the
condition in `AC-OFFICE-SEAT-GUARD-001.2`. Finding the seat decided is a
normal outcome and shall be reported as success.
- **AC-OFFICE-SEAT-GUARD-001.7:** This requirement adds no acceptance criterion
to the participant-seat-provenance contract and amends none of its criteria.
It names the path on which `AC-OFFICE-SEAT-PROVENANCE-002.5` is defended by
the condition on the update alone.
- **AC-OFFICE-SEAT-GUARD-001.8:** Any decision against the seat shall block the
reassignment, whether or not it has been superseded, and whether or not more
than one exists. Both defenses shall agree on this: a seat the selection
rejects as decided shall not be one the update would accept, and the reverse.
Superseded decisions are retained as the audit trail of a reworked review, and
a seat that has ever been decided has an attribution history that reassigning
it would falsify.
- **AC-OFFICE-SEAT-GUARD-001.9:** A registration that declined to reassign under
`AC-OFFICE-SEAT-GUARD-001.2` shall leave the registering agent seated by the
new seat it wrote, so a repeat of the same registration is governed by the
existing already-seated rule and writes nothing further. This document adds no
retry and no second attempt at the claim.
### REQ-OFFICE-SEAT-GUARD-002: The guard is verified by a test that fails without it
**Intent:** A guard whose test passes when the guard is deleted is not covered.
The suite must contain at least one test that distinguishes the two, and the
tests that cannot must stop claiming they do.
#### Acceptance criteria
- **AC-OFFICE-SEAT-GUARD-002.1:** The test suite shall contain a test that
fails when the condition required by `AC-OFFICE-SEAT-GUARD-001.3` is removed
from the reassigning update, and that passes when it is present.
- **AC-OFFICE-SEAT-GUARD-002.2:** That test shall reach the window described in
`AC-OFFICE-SEAT-GUARD-001.2` deterministically. Its result shall not depend
on thread or transaction scheduling, and it shall not be capable of passing
by never entering the window it targets.
- **AC-OFFICE-SEAT-GUARD-002.3:** Any mechanism introduced to make
`AC-OFFICE-SEAT-GUARD-002.2` deterministic shall have no effect in a
production build: with the mechanism unset, the code path shall behave
exactly as it does today.
- **AC-OFFICE-SEAT-GUARD-002.4:** A test that establishes that the two writers
serialize on the seat exclusion shall say that, and shall assert an outcome
that serialization can produce. It shall not document itself as establishing
that the condition on the update is load-bearing, which serialization
prevents it from showing.
- **AC-OFFICE-SEAT-GUARD-002.5:** No test documentation in the suite shall
assert that role-carrying decision recording and registration lock on
non-contending exclusions. That premise is false and is the reason
`AC-OFFICE-SEAT-GUARD-002.4` is needed.
## Out of scope
- **Removing the condition on the reassigning update.** Named because the
premise that it is unreachable is the tempting conclusion from the shared
exclusion, and it is wrong: `AC-OFFICE-SEAT-GUARD-001.3` requires it to stay,
and the roleless decision path is why.
- **Making the roleless decision path acquire the seat exclusion.** This is the
other way to close the window, and it is deliberately not taken here. The
path has no role to key the exclusion on and would have to derive one from
the seat it references, turning a write into a read-then-lock and widening
decision recording's contention for a path no shipped caller uses. The
condition on the update already delivers the outcome
`AC-OFFICE-SEAT-GUARD-001.2` requires. Recorded so the trade is on the record
rather than an omission.
- **Rejecting roleless decisions.** Whether the decision store should require a
role at all is a separate contract question about the decision model. This
document takes the store's accepted input shapes as given and requires the
seat behavior to be correct for all of them.
- **The concurrency test covering a claim target removed mid-transaction.** A
sibling follow-up owns that test and its own determinism gap. If a shared
mechanism satisfies `AC-OFFICE-SEAT-GUARD-002.3` for both, that is a welcome
outcome and not a requirement of this document, which constrains only the
decision guard's own coverage.
- **Any user-visible surface.** No behavior change ships to a user from this
document: `AC-OFFICE-SEAT-GUARD-001` records what the code already does. No
copy, no i18n, no UI work.
## Prior art
The prior-art legs for this card are recorded once, in
[Office Task Session Termination Requirements](task-session-termination.md#prior-art),
with their receipts. Both external legs were unavailable on this runner; the
in-repo prior decisions that applied to this document are
`AC-OFFICE-SEAT-PROVENANCE-002.5`, which this requirement defends without
amending, and the shared seat exclusion introduced to close the role-carrying
decision race, whose own documentation records that the two writers previously
never contended.
## System design
The technical design is
[part 1](../system-design/seat-claim-decision-guard-01.md).
@@ -0,0 +1,354 @@
---
status: draft
system: office
created: 2026-09-08
owners:
- kandev
---
# Office Task Session Termination Requirements
## Overview
An office session is keyed on a `(task_id, agent_profile_id)` pair.
REQ-OFFICE-SESSION-IDENTITY-001 makes that pair name exactly one live durable
conversation, and `AC-OFFICE-SESSION-IDENTITY-001.8` states the consequence
explicitly: the office side treats the pair as one conversation *regardless of
which path created the row*. Nothing in that contract says when the
conversation ends.
Three paths end it today, and all three end it on the loss of a single
capacity: a participant role is removed by hand, a seat is taken from its
occupant by a manual claim, or the task is reassigned. Each flips the one
shared session row to `COMPLETED` without asking whether the agent still holds
another capacity on the same task.
That question is not hypothetical. `AC-OFFICE-REVIEW-SEATS-002.4`, `-002.5`
and `-002.9` each seat the task's runner, or an agent already seated in another
role, as a reviewer. An agent holding two capacities at once is a normal,
fallback-default outcome of the shipped casting rules, and when such an agent
loses one of them the current behavior ends the session it is still using for
the other.
In the configuration every profile ships (`features.officeSessionIdentity`
disabled), the defect is sharper than "sometimes over-eager". With that flag
off, a task that has a runner routes *every* office run through the session
owned by the runner, so no session is keyed on a non-runner participant at all.
A termination aimed at a displaced or removed non-runner therefore finds
nothing and does nothing, and a termination aimed at an agent that *is* the
runner finds the task's only live session. On the role-removal and seat-claim
paths, the operation is either a silent no-op or the exact case this document
forbids. Enabling the flag gives each agent its own row and removes the no-op
half, but not the defect: the self-review agent still holds one row for both
capacities.
Office owns this contract because office owns the rule that a pair names one
conversation, and owns all three paths that end one. The task system owns the
session row's storage and its non-office lifecycle, which this document leaves
untouched.
## Terminology
- **Capacity** - a distinct standing an agent holds on a task that causes the
system to address it there. Two exist: the runner capacity and a seat
capacity.
- **Runner capacity** - the agent is the task's effective runner, as that is
resolved for every other reader of the task's runner.
- **Seat capacity** - the agent holds a seat in the task's effective
participant slate, in any role.
- **Effective participant slate** - the seats a reader of the task observes at
the task's current step, after per-task rows take precedence over
template-level rows. It is the same slate the task's own readers see; this
document does not define a second one.
- **Guarded path** - one of the three paths this document constrains: manual
removal of a participant role, displacement of a seat's occupant by a claim,
and reassignment of the task away from its runner.
- **Cascade path** - a path that ends every live session belonging to an agent
across every task, because the agent instance itself is going away.
- **Live** and **terminal** - as defined by
REQ-OFFICE-SESSION-IDENTITY. This document adds no state and redefines
neither.
## Prior art
**Our own prior reasoning (wiki):** the leg did not run, and no result is
reported for it. Receipt: the `wiki-query` skill is not installed on this
runner (absent from `~/.claude/skills/` and from the task-local
`.claude/skills/`), `OBSIDIAN_VAULT_PATH` is unset, `~/.obsidian-wiki/` does
not exist, no vault directory exists under `~`, and `qmd` is not on `PATH`, so
neither the QMD path nor the documented `grep` fallback had a vault to resolve.
Recorded as unavailable rather than as an empty result.
**What others shipped (saas-kb):** the leg did not run. Receipt: no `saas-kb`
MCP server is exposed to this session - the only MCP tools present are the
`kandev` server's - so `search_fsm_docs` could not be called with or without
`category: "ai_sdlc"`. Recorded as unavailable, not as a search that found
nothing.
**In-repo prior decisions**, which did apply and which carried the weight the
two unavailable legs would have:
- **REQ-OFFICE-SESSION-IDENTITY-001 and -004** forecloses the obvious fix.
Making session identity role-scoped would add a role dimension to the pair
that -001 has just finished making singular, and
`AC-OFFICE-SESSION-IDENTITY-004.5` forbids the new column such a dimension
would need. The card that raised this defect offered role-scoped identity or
a retained-capacity guard as alternatives; that contract decides between
them, and this document takes the guard.
- **`AC-OFFICE-SEAT-PROVENANCE-002.12`** already scopes the claim path's
termination to an agent "holding a live session for that task **in that
role**". The implementation is broader than its own frozen criterion, because
the session model cannot express "in that role". This document does not amend
that criterion; it makes the behavior conform to what the criterion already
says.
- **`AC-OFFICE-REVIEW-SEATS-002.3`** already treats an agent holding two
capacities as undesirable-but-permitted, preferring a candidate that is
"neither the task's runner nor already seated in another participant role"
and falling back when none exists. The fallback is what makes this defect
reachable, and it is deliberate, so the fix belongs on the termination side
rather than in casting.
- **ADR 0005 (agent model unification)** made `agent_profile_id` shared between
kanban and office, which is why the capacity question must be answered on the
office code path rather than by a constraint on the shared row.
**What we are doing differently:** the shape this codebase reaches for when a
lifecycle event is over-broad is a narrower key. That is rejected here for the
first reason above, and the alternative is unusual enough to name: the key stays
as wide as it is and the *decision to act on it* acquires a precondition.
Ending the row becomes conditional on "this agent's one conversation about this
task" having stopped being true, rather than on one reason to suspect it might
have.
## Requirements
### REQ-OFFICE-SESSION-TERM-001: A session ends only with the last capacity
**Intent:** A guarded path removes one capacity. It must end the agent's
session only when that was the agent's last one, so an agent that is still
addressed on the task keeps the conversation it is still using.
**User story:** As an operator claiming a review seat from the agent that is
also writing the code, I want that agent to keep working, so that taking over
the review does not silently stop the task.
#### Acceptance criteria
- **AC-OFFICE-SESSION-TERM-001.1:** When a guarded path would end an agent's
office session for a task, the system shall determine whether that agent
retains a capacity on that task, and shall end the session only when it
retains none.
- **AC-OFFICE-SESSION-TERM-001.2:** The determination shall observe state in
which the mutation that removed the capacity has already committed, so the
capacity being removed is never itself observed as retained. A determination
made before that commit would suppress every termination and satisfies
nothing in this document.
- **AC-OFFICE-SESSION-TERM-001.3:** When the agent retains at least one
capacity, the system shall leave the session row entirely unwritten - its
state, its reason and every other field unchanged - and shall report success
to the guarded path's caller. A suppressed termination is not an error and
shall not be reported as one.
- **AC-OFFICE-SESSION-TERM-001.4:** When the agent retains no capacity, the
system shall end the session exactly as it does today: the row becomes
`COMPLETED` carrying the reason that path already supplies, and no other
observable behavior of that path changes.
- **AC-OFFICE-SESSION-TERM-001.5:** The determination shall have exactly one
implementation in application code, reachable from all three guarded paths,
and the system shall not introduce a second. Three copies that agree today
are three copies that can disagree later, which is the failure this criterion
exists to prevent.
- **AC-OFFICE-SESSION-TERM-001.6:** The guarded paths shall be exactly three:
removal of a participant role from a task, displacement of a seat's occupant
by a claim on that seat, and reassignment of a task away from its runner. The
system shall not extend the determination to a path not named here without
amending this criterion.
- **AC-OFFICE-SESSION-TERM-001.7:** If the determination cannot be completed
because the state it reads is unavailable, then the system shall not end the
session, shall record the failure under REQ-OFFICE-SESSION-TERM-004, and
shall report success to the guarded path's caller. The determination fails
closed: ending the session of an agent that is still working is the defect
being removed, and leaving a session row live is recoverable by the next
guarded path that runs, whereas ending one is not.
- **AC-OFFICE-SESSION-TERM-001.8:** When either the task identifier or the
agent profile identifier is empty, the system shall neither evaluate the
determination nor end a session, and shall report success. No pair is named,
so there is nothing to decide.
- **AC-OFFICE-SESSION-TERM-001.9:** When no live session exists for the pair,
the observable outcome shall be identical whether or not the determination
was evaluated. The system may skip it, and a test shall not depend on whether
it did.
- **AC-OFFICE-SESSION-TERM-001.10:** When two guarded paths run concurrently
for the same agent and task, each removing a different capacity, the system
shall end the session. This follows from
`AC-OFFICE-SESSION-TERM-001.2` without further coordination: each path reads
strictly after its own commit, so whichever commits second observes both
removals and finds no retained capacity. The system shall not add a lock, a
retry or a re-read to obtain this outcome, and shall not rely on which path
reads first.
- **AC-OFFICE-SESSION-TERM-001.11:** The system shall not end a session that
the same guarded path has already ended, and repeating a guarded path's
termination step for a pair shall produce no further write. This preserves
the existing idempotency of the termination step unchanged.
### REQ-OFFICE-SESSION-TERM-002: What counts as a retained capacity
**Intent:** The determination must ask a question the rest of the system
already answers the same way, and must ask it over a scope that cannot pick up
a capacity the agent no longer holds.
#### Acceptance criteria
- **AC-OFFICE-SESSION-TERM-002.1:** The system shall treat an agent as
retaining a capacity on a task when it is the task's effective runner, or
when it holds a seat in the task's effective participant slate, and shall
treat it as retaining none otherwise. The two are alternatives, not an
ordered test: neither takes precedence and the determination shall not depend
on which is evaluated first.
- **AC-OFFICE-SESSION-TERM-002.2:** The system shall resolve the task's
effective runner by the same rule every other reader of that task's runner
uses, so that the determination and the rest of the system cannot disagree
about who the runner is. It shall not resolve the runner by reading
participant seats directly, which would answer a different question: seats
written for a step the task has since left remain readable, and the runner
rule already fixes their precedence.
- **AC-OFFICE-SESSION-TERM-002.3:** The seat scope shall be the task's
effective participant slate at the task's current step. A seat recorded at a
step the task is not standing on shall not, on its own, count as a retained
capacity. Without this bound a task that has moved through steps accumulates
seats naming a previous occupant, and a termination that is correct would be
suppressed forever.
- **AC-OFFICE-SESSION-TERM-002.4:** A template-level seat that the slate
projects onto the task shall count exactly as a per-task seat does. The agent
is addressed on the task either way, which is the only thing the
determination asks.
- **AC-OFFICE-SESSION-TERM-002.5:** Every participant role shall count,
including a role that carries no decision obligation. The determination asks
whether the agent is still addressed on the task, not whether it still owes a
decision.
- **AC-OFFICE-SESSION-TERM-002.6:** The determination shall depend on the
presence of a capacity and not on how many the agent holds. An agent holding
two seats and an agent holding one shall be treated identically.
- **AC-OFFICE-SESSION-TERM-002.7:** The determination shall not depend on the
order in which seats are returned, and shall produce the same result for any
ordering of the same slate.
- **AC-OFFICE-SESSION-TERM-002.8:** When the task has no current step, the
effective slate shall be empty and only the runner capacity shall be capable
of being retained. When the task does not resolve at all, the determination
shall be treated as having failed under `AC-OFFICE-SESSION-TERM-001.7` rather
than as having found no capacity.
- **AC-OFFICE-SESSION-TERM-002.9:** The determination shall compare agent
profile identifiers and shall not require the named profile to resolve to a
live agent. A seat naming a profile that no longer resolves still names this
agent, and an agent instance that is genuinely going away is handled by a
cascade path instead.
- **AC-OFFICE-SESSION-TERM-002.10:** The determination shall produce the same
result on every database engine the product supports.
- **AC-OFFICE-SESSION-TERM-002.11:** The determination selects nothing. It
answers whether any capacity is held, so no ordering, tiebreak or
deterministic choice among capacities is required, and the system shall not
introduce one. Which capacity is named in `AC-OFFICE-SESSION-TERM-004.1`'s
record may be any that was found.
- **AC-OFFICE-SESSION-TERM-002.12:** When a capacity is granted to the agent
between the guarded path's commit and the determination's read, the system
shall observe it and suppress the termination. An agent that has just been
seated again is addressed on the task, and the criterion asks about the
present, not about the state at the moment the capacity was lost.
### REQ-OFFICE-SESSION-TERM-003: What this contract does not change
**Intent:** This is a narrow precondition on three call sites. It is stated
negatively as well as positively so that an implementation cannot satisfy it by
a mechanism that also reaches the paths that must keep working, and so that the
inconsistency it deliberately leaves in place is not "fixed".
#### Acceptance criteria
- **AC-OFFICE-SESSION-TERM-003.1:** A cascade path shall remain unguarded and
shall continue to end every live session belonging to the agent across every
task. An agent instance being deleted or reconciled away retains no capacity
anywhere by definition, so evaluating the determination there would be a cost
with no possible effect.
- **AC-OFFICE-SESSION-TERM-003.2:** On the reassignment path, cancellation of
the previous runner's *running execution* shall be unchanged, and shall
continue to happen whether or not the session row's termination is
suppressed. The two are deliberately decoupled: the agent must stop the work
it is no longer the runner for, and must keep the conversation it is still
seated for. An implementation that suppresses the cancellation alongside the
termination does not satisfy this document.
- **AC-OFFICE-SESSION-TERM-003.3:** Session creation, reuse, lookup and
identity shall be unchanged. The system shall introduce no new column on the
session store, no new index on it, and no backfill or repair of existing
rows.
- **AC-OFFICE-SESSION-TERM-003.4:** A suppressed termination shall change
exactly one thing relative to today: the session row is not written. It shall
not withhold, delay, duplicate or reorder any participant seat write,
decision, queued or cancelled run, activity entry or change notification that
its guarded path performs, and shall not itself wake, queue or re-queue
anything.
- **AC-OFFICE-SESSION-TERM-003.5:** The behavior required by
REQ-OFFICE-SESSION-TERM-001 and -002 shall hold identically whether
`features.officeSessionIdentity` is enabled or disabled. The defect is
reachable in both states and the fix shall not be conditioned on either.
- **AC-OFFICE-SESSION-TERM-003.6:** No participant casting, seat provenance, or
claim behavior shall change. This document adds no acceptance criterion to
the participant-seat-provenance contract and amends none of its criteria.
### REQ-OFFICE-SESSION-TERM-004: A suppressed termination is observable
**Intent:** The new outcome is a session that stays live where one used to end.
Left unrecorded it is indistinguishable from a termination that failed, from a
path that never ran, and from the leak a mistake in
`AC-OFFICE-SESSION-TERM-002.3` would produce.
#### Acceptance criteria
- **AC-OFFICE-SESSION-TERM-004.1:** When a termination is suppressed because
the agent retains a capacity, the system shall emit a record identifying the
task, the agent profile, the guarded path's reason, and which capacity was
retained.
- **AC-OFFICE-SESSION-TERM-004.2:** When a termination is suppressed because
the determination failed, the system shall emit a record that is
distinguishable from `AC-OFFICE-SESSION-TERM-004.1`'s and that identifies the
failure. A read fault and a retained capacity are different events and shall
not be reported as one.
- **AC-OFFICE-SESSION-TERM-004.3:** The system shall increment a counter for
each suppression, labelled only by bounded dimensions. It shall not label a
counter with a task, agent, step or session identifier.
- **AC-OFFICE-SESSION-TERM-004.4:** A termination that proceeds shall continue
to record what it records today, with the same reason values. This document
adds no reason value and retires none.
## Out of scope
- **Role-scoped session identity.** Named because the card that raised this
defect offered it as the alternative fix, and because it is the shape a
reader will reach for first. It would add a role dimension to the
`(task, agent)` pair that REQ-OFFICE-SESSION-IDENTITY-001 exists to make
singular, and would need a discriminator column
`AC-OFFICE-SESSION-IDENTITY-004.5` forbids. Rejected here, not deferred: the
guard in REQ-OFFICE-SESSION-TERM-001 delivers the same observable outcome
with no change to session identity at all.
- **Repairing sessions already ended by the current behavior.** No migration,
backfill or repair is authorized by this document. A session wrongly
completed before this contract ships stays completed; the pair's next office
wakeup creates a fresh row by the existing rules, which is the recovery that
already exists.
- **Ending a session when an agent loses its last capacity by a path not named
in `AC-OFFICE-SESSION-TERM-001.6`.** An agent can stop being addressed on a
task in ways none of the three guarded paths observes - a step transition
that changes the slate, for one. Those paths end no session today and this
document does not make them start: the scope is the over-eager termination
that exists, not an audit of session lifetime.
- **The `features.officeSessionIdentity` rollout.** Whether that flag is
promoted, and what its enablement requires, is its own decision.
`AC-OFFICE-SESSION-TERM-003.5` only requires this contract to be indifferent
to it.
- **The casting rules that make an agent hold two capacities.**
`AC-OFFICE-REVIEW-SEATS-002.3` through `-002.9` are deliberate and unchanged.
- **Any user-visible surface.** The observable consequence is that a session
stays in the live participant indicators the frontend already renders from
existing session state. No new copy, no new component, no i18n work.
## System design
The technical design is
[part 1](../system-design/task-session-termination-01.md).
@@ -0,0 +1,180 @@
---
status: draft
system: office
requirements:
- REQ-OFFICE-SEAT-GUARD-001
- REQ-OFFICE-SEAT-GUARD-002
---
# Office Seat Claim Decision Guard System Design
## Purpose and boundaries
Office owns the registration path that claims a seat, and the office-facing
entry points that record a decision. This design changes no production
behavior: it records which of two overlapping defenses covers which window, and
makes the one that is currently untested testable.
Contracts used but not owned: the workflow repository's decision store and the
per-`(task, role)` seat exclusion it shares with registration.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-OFFICE-SEAT-GUARD-001` | [Two defenses, two windows](#two-defenses-two-windows) |
| `REQ-OFFICE-SEAT-GUARD-002` | [Making the window reachable](#making-the-window-reachable), [Verification obligations](#verification-obligations) |
## Two defenses, two windows
A registration that finds a claimable seat runs two statements inside one
transaction: a select that chooses the seat, and an update that reassigns it.
The **select** rejects a seat that already carries a decision. It is a plain
read inside the registration's transaction and it sees everything committed
before it ran.
The **update** carries a `NOT EXISTS` condition over decisions against the same
seat, and re-checks the seat's provenance. It can only add something the select
did not already provide when a decision commits *between* the two statements.
Whether anything can commit there is entirely a question about the seat
exclusion:
- **A decision carrying a role** acquires the exclusion, which the registration
holds for its whole transaction. It cannot commit in the window. The update's
condition is unreachable through this path, and a test that races this path
cannot distinguish the condition being present from its being absent.
- **A decision carrying no role** does not acquire the exclusion. The decision
writer's own code branches on the role being empty - it supersedes by
participant instead of by decider-and-role, and skips the lock with the
reasoning that there is nothing to serialize against. That reasoning is
incomplete: there is something, and it is this window. For this path the
update's condition is the whole defense.
Neither defense filters on `superseded_at`, and `AC-OFFICE-SEAT-GUARD-001.8`
requires that they keep agreeing. A seat that has ever been decided stays
unclaimable: the superseded rows are the audit trail of a reworked review, and
reassigning a seat that carries one would leave a verdict attributed to an agent
that did not give it. Adding a `superseded_at IS NULL` filter to either
statement would make the two disagree, and would make a reworked seat claimable
by whoever registers next.
Because the condition matches on the seat identity rather than on the
decision's role, it covers the roleless decision without modification. Nothing
in the production code needs to change for
`REQ-OFFICE-SEAT-GUARD-001` to hold - which is why that requirement is written
as a statement of existing behavior with the reachability named, not as a
change order.
No shipped caller produces a roleless decision: both office entry points
resolve a role first and refuse the request when they cannot. The path is
supported by the store and unused by the product, which is the precise sense in
which this is latent.
## Making the window reachable
`AC-OFFICE-SEAT-GUARD-002.2` requires the test to enter the window
deterministically. Two approaches were considered.
**Racing real goroutines** on the roleless path would genuinely reach the
window, because that path takes no exclusion. It is rejected: nothing forces
the interleaving, so the test would pass most of the time by never entering the
window at all - which is the same defect being fixed here, reintroduced in a
different test. A sibling follow-up already records this failure mode against a
neighbouring concurrency test.
**A test-only interleaving hook** is the design. An unexported package-level
function value in the office repository's sqlite package, called between the
select and the update, is nil in every build that does not set it. The
test-only `SetClaimWindowHook` setter in `export_test.go` makes the yield point
available to the external `sqlite_test` package. The SQLite test writes the
roleless decision through the in-flight transaction. The Postgres-gated test
uses the workflow repository from the hook, so that decision commits through a
second pooled connection before the update runs.
Three properties make this acceptable rather than a production concession, and
they map to `AC-OFFICE-SEAT-GUARD-002.3`:
- The variable is unexported, and its setter is compiled only into the test
binary, so production code cannot set it and it needs no build tag.
- Unset, the call site is a nil check on a value that is never written, so the
production path is byte-for-byte the behavior that ships today.
- It carries no behavior of its own. It is a yield point, not a policy hook,
and nothing production reads it.
The hook is placed in the registration's claim attempt, between the statement
that chooses the seat and the one that reassigns it. That is the only window
the requirement is about.
If the sibling follow-up's claim-target-removed test wants the same yield
point, it is the same hook at the same site. That is a welcome economy and not
a dependency in either direction - neither card's tests need the other's.
## Data and contracts
No schema change, no production interface change, and no production export. The
test binary exposes only the setter needed by the external-package tests. The
condition on the update, the select's filter and the seat exclusion all stay
exactly as they are.
## Control flow
Unchanged in production. Under test, one nil-valued call site between two
existing statements of an existing transaction. The SQLite test uses that
transaction directly; the Postgres test uses a separate pooled connection to
prove a real cross-transaction commit.
## Failure and recovery
Unchanged. A registration that finds the seat decided at update time affects
zero rows, falls through to inserting a new seat, and reports success - the
existing fallthrough, which `AC-OFFICE-SEAT-GUARD-001.5` and `-001.6` describe
rather than modify. Nothing is retried and nothing is failed.
## Persistence
No schema change, no migration, no backfill. The registration's transaction
boundaries and the exclusion it acquires are untouched.
## Security
No new surface. The hook is unexported, unset in production, and carries no
input from any request.
## Observability
Nothing new. The declined-claim outcome is already indistinguishable from "no
claimable seat existed" by design (`AC-OFFICE-SEAT-GUARD-001.5`), and this
design does not make it distinguishable - a new signal there would report a
path no shipped caller reaches.
## Verification obligations
Three changes to the suite, and one of them is a deletion of a claim rather
than of a test:
1. **The new guard test** (`-002.1`, `-002.2`). Drives a registration with the
hook set, writes a roleless decision against the chosen seat inside the
window, and asserts the decision and seat are unchanged while a new seat is
written for the registering agent. The SQLite test writes through the open
transaction; the Postgres-gated variant commits from a second connection.
Removing the `NOT EXISTS` condition must make these tests fail; that is the
acceptance check for the tests themselves, and should be performed once by
hand when they are written.
2. **The existing concurrency test's documentation** (`-002.4`, `-002.5`). Its
stated premise - that decision recording and registration lock on
non-contending namespaces - is false since the shared exclusion landed. It
is re-documented as establishing what it can: that the two writers serialize
on the shared exclusion, complete without deadlock in either acquisition
order, and leave no reattributed decision. The test's assertions already
support that reading; only its docstring asserts more than it shows.
3. **No test is deleted or weakened.** The existing concurrency test keeps its
iterations and its multi-connection pool - serialization under real
concurrency is worth covering, and the deadlock question is real, since the
decision path acquires two exclusions in a fixed order and the registration
path acquires one of them.
## Related decisions
- [ADR 0005 - agent model unification](../../../decisions/0005-agent-model-unification.md)
@@ -0,0 +1,227 @@
---
status: draft
system: office
requirements:
- REQ-OFFICE-SESSION-TERM-001
- REQ-OFFICE-SESSION-TERM-002
- REQ-OFFICE-SESSION-TERM-003
- REQ-OFFICE-SESSION-TERM-004
---
# Office Task Session Termination System Design
## Purpose and boundaries
Office owns the rule that a `(task, agent)` pair names one durable conversation
and owns the three paths that end one. This design adds a precondition to those
three paths and changes nothing else.
Contracts this design uses but does not own: the session row's storage and
state machine (task system); the participant seat store and the exclusion that
protects it (workflow system); the effective-runner resolution shared by every
reader of a task's runner (office task projection). None of the three is
modified.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-OFFICE-SESSION-TERM-001` | [Where the precondition lives](#where-the-precondition-lives), [Control flow](#control-flow) |
| `REQ-OFFICE-SESSION-TERM-002` | [The capacity question](#the-capacity-question) |
| `REQ-OFFICE-SESSION-TERM-003` | [Control flow](#control-flow) |
| `REQ-OFFICE-SESSION-TERM-004` | [Observability](#observability) |
## Components and responsibilities
- **The office dashboard service** owns the three guarded call sites
(`internal/office/dashboard/service_tasks.go`) and gains one new private
helper that answers the capacity question. It already holds the office
repository, which is the only component that can answer it.
- **The orchestrator's session terminator**
(`internal/orchestrator/office_session_terminator.go`) is unchanged. It stays
the dumb executor of "flip this pair's row to `COMPLETED`, idempotently", and
keeps both its methods and its two interfaces exactly as they are.
- **The office repository** supplies the two reads the question needs. Both
already exist: the task's execution fields, whose assignee field is the
shared effective-runner projection, and the task's effective participant
slate at its current step.
## Where the precondition lives
The guard goes in the dashboard service, immediately before each
`TerminateOfficeSession` call, not inside the terminator.
The terminator is in `internal/orchestrator` and reaches the session repository
only. The capacity question needs participant seats and the runner projection,
both owned by `internal/office/repository/sqlite`, which the orchestrator does
not and should not import - the `SessionTerminator` interfaces exist precisely
so office does not import orchestrator and orchestrator does not import office.
Pushing the question down would either invert that dependency or widen the
interface with a callback, for no gain.
Putting it in the service also keeps the two cascade paths correct by
construction. `TerminateAllForAgent` is a different method on the same type
with different callers (agent deletion, config-sync reconciliation), and
`AC-OFFICE-SESSION-TERM-003.1` requires it to stay unguarded. A guard inside
the shared terminator would have to be argued out of that method; a guard in
the dashboard service never reaches it.
`AC-OFFICE-SESSION-TERM-001.5` requires one implementation. The three call
sites are three lines in one file calling one unexported helper on the same
receiver; there is no second copy to drift.
## The capacity question
One helper, one signature: given a task and an agent profile, does the agent
still hold a capacity on that task?
**The runner capacity** is read from the task's execution fields, whose
assignee value is produced by the shared runner projection
(`RunnerProjection`). `AC-OFFICE-SESSION-TERM-002.2` requires this rather than
a direct read of runner seats, and the reason is specific: that projection has
three precedence tiers, and its last tier deliberately reads runner rows across
*all* steps, ordered by creation time. A hand-written "is there a runner seat
naming this agent" query would therefore disagree with the projection exactly
when a task has moved between steps - which is the case the guard is most
likely to meet. Reusing the projection makes disagreement impossible instead of
unlikely.
**The seat capacity** is read from the task's effective slate at its current
step (`ListAllTaskParticipants`), which already applies per-task precedence
over template-level rows and already scopes to the current step. That scoping
is what `AC-OFFICE-SESSION-TERM-002.3` requires and it is load-bearing: seats
are keyed `(step, task, role, agent)` and are not cleaned up when a task
leaves a step, so an unscoped read would find a seat naming a previous occupant
and suppress a correct termination permanently. The slate read is also what
`AC-OFFICE-SESSION-TERM-002.4` and `-002.5` fall out of: it already merges
template rows in, and it already returns every role.
Neither read is ordered-dependent (`-002.7`): the helper asks whether any
returned row names the agent, and stops. Both are plain reads on the read pool,
taking no lock and joining no transaction - the guard is not trying to be
atomic with anything, and `AC-OFFICE-SESSION-TERM-001.10` explains why it does
not need to be.
**Failure is closed** (`-001.7`). Either read failing means "do not terminate",
recorded and swallowed. This inverts the usual best-effort default in these
call sites, deliberately: the surrounding code swallows a *termination*
failure because a session left live is recoverable, and the same reasoning says
a session wrongly ended is not.
## Control flow
Each guarded path already commits its mutation before it reaches the
termination step, which is what `AC-OFFICE-SESSION-TERM-001.2` needs and why no
reordering is required:
1. **Role removal.** The participant delete commits, then the service asks the
question and terminates only on "no capacity". The removed role is already
absent from the slate.
2. **Seat claim.** The claim transaction commits with the seat's agent profile
already reassigned to the claiming agent, then the post-commit effects run
on a cancellation-detached context. The displaced agent no longer holds that
seat when the question is asked. The question is inserted before the
existing termination call and leaves the displaced-run cancellation beside
it untouched (`AC-OFFICE-SESSION-TERM-003.4`).
3. **Reassignment.** The assignee update commits - which also rewrites the
runner seat at the task's current step - then the reactivity pipeline runs,
then the termination step. By the time the question is asked, the projection
already returns the new runner, so the previous runner is retained only if
it holds a seat in another role.
The third is where `AC-OFFICE-SESSION-TERM-003.2` matters. The reactivity
pipeline's hard cancel of the previous runner's running execution happens
*before* the termination step and is not guarded. An agent reassigned away from
the runner role but still seated as a reviewer stops the coding turn it no
longer owns and keeps the row it still reviews through. Those are two different
objects, and the design keeps them decoupled on purpose.
### Concurrency
`AC-OFFICE-SESSION-TERM-001.10` is satisfied without coordination, and the
argument is worth recording because the obvious reading is that two concurrent
removals could both suppress.
Each path reads strictly after its own commit. Take two paths removing
different capacities from the same agent: whichever commits second issues its
read after both commits are visible, observes neither capacity, and terminates.
The first may well suppress - it can still see the second's capacity - but the
outcome is the pair ending with a terminated session either way. The window
therefore closes itself, and adding a lock or a re-read would buy nothing.
This depends only on each read being a fresh statement issued after that
caller's own commit, which holds on both engines under their default isolation.
It does not depend on which caller reads first.
## Failure and recovery
- A failed capacity read suppresses the termination and is recorded
(`-001.7`, `-004.2`). The session row stays live and is re-evaluated by the
next guarded path that runs on the pair.
- A suppressed termination is not an error to its caller (`-001.3`). All three
call sites already treat termination as best-effort and log rather than
propagate; suppression is quieter than that and returns success.
- A session left live because a capacity was retained needs no recovery: it is
the conversation the agent is still using, and the existing reuse rules will
hand it back on the agent's next wakeup.
- A session left live by a *mistaken* retained-capacity answer is the residual
risk this design accepts, and `-004.1`'s record with the retained capacity
named is how it is diagnosed.
## Persistence
No schema change. No new column, index, migration or backfill
(`AC-OFFICE-SESSION-TERM-003.3`). The guard adds two reads on the read-only
pool per guarded termination and removes some writes; it opens no transaction
and takes no lock.
Sessions already ended by the pre-existing behavior are left as they are - the
requirement's "Out of scope" says so explicitly, and no repair is authorized.
## Security
No new surface. All three guarded paths already enforce their own
authorization before reaching the termination step - the participant mutators
behind the approve-permission gate, reassignment behind the assign-tasks gate -
and the guard runs after those checks, never instead of one. The question reads
only state the caller has already been authorized to mutate.
## Observability
Suppression is logged at the existing call sites' logger with the task, agent
profile, guarded reason and which capacity was retained (`-004.1`), and a read
failure is logged distinguishably (`-004.2`). Both follow the structured-zap
idiom the surrounding code already uses.
A counter under the office namespace counts suppressions, labelled only by
bounded dimensions - the guarded reason and which capacity was retained
(`-004.3`). Neither is unbounded: the reasons are a fixed set of three and the
capacity is one of two. No task, agent, step or session identifier is a label,
matching `AC-OFFICE-REVIEW-SEATS-004.6`'s rule for the same reason.
Terminations that proceed keep their existing reason values and log line
(`-004.4`), so the two outcomes remain comparable in one place.
## Verification obligations
The capacity question's own tests are ordinary. Two are worth naming because
they are the ones that fail silently if written casually:
- The **step-scoping** criterion (`-002.3`) needs a task that has *moved*: a
seat written at a step the task has left, naming the agent, must not suppress
a termination. A test that never moves the task passes whether or not the
scope is bounded.
- The **decoupling** criterion (`-003.2`) needs a reassignment of an agent that
keeps a reviewer seat, asserting both halves: the execution is cancelled
*and* the session row is untouched. Asserting only the second passes for an
implementation that wrongly suppresses both.
The shipped-configuration reachability described in the requirement's overview
also gives a direct regression test that needs no flag: with
`features.officeSessionIdentity` off, an agent that is both runner and
auto-cast reviewer holds the task's only session, and displacing it from the
reviewer seat must leave that session live.
## Related decisions
- [ADR 0005 - agent model unification](../../../decisions/0005-agent-model-unification.md)