fix(office): stop multi-agent reviews from deadlocking by default (#3515)

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-09 14:52:18 +08:00
committed by GitHub
parent 1dd6d4bc9d
commit ecd3efc01b
11 changed files with 527 additions and 58 deletions
@@ -220,32 +220,32 @@ func stringContains(value, want string) bool {
func newCanvasEditTestDependencies() (*fakeCanvasEditTaskStore, *fakeCanvasEditCanvasStore, *fakeCanvasEditReleaseStore, *fakeCanvasEditArtifactStore) {
return &fakeCanvasEditTaskStore{
workspace: &models.Workspace{
ID: "workspace-a",
DefaultAgentProfileID: stringPointer("profile-default"),
DefaultExecutorID: stringPointer("executor-default"),
},
}, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{
ID: "canvas-a",
PluginInstanceID: "instance-a",
WorkspaceID: "workspace-a",
ScopeKind: canvas.ScopeWorkspace,
Status: instances.StatusActive,
ActiveReleaseID: "release-a",
ActiveReleaseStatus: instances.ValidationValid,
EffectiveGrants: []canvas.GrantProjection{{
PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace,
}},
}}, &fakeCanvasEditReleaseStore{release: instances.Release{
ID: "release-a",
InstanceID: "instance-a",
PackageDigest: "digest-a",
ArtifactPath: "releases/digest-a",
ValidationStatus: instances.ValidationValid,
}}, &fakeCanvasEditArtifactStore{files: map[string][]byte{
"manifest.yaml": []byte("manifest"),
"index.html": []byte("index"),
}}
workspace: &models.Workspace{
ID: "workspace-a",
DefaultAgentProfileID: stringPointer("profile-default"),
DefaultExecutorID: stringPointer("executor-default"),
},
}, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{
ID: "canvas-a",
PluginInstanceID: "instance-a",
WorkspaceID: "workspace-a",
ScopeKind: canvas.ScopeWorkspace,
Status: instances.StatusActive,
ActiveReleaseID: "release-a",
ActiveReleaseStatus: instances.ValidationValid,
EffectiveGrants: []canvas.GrantProjection{{
PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace,
}},
}}, &fakeCanvasEditReleaseStore{release: instances.Release{
ID: "release-a",
InstanceID: "instance-a",
PackageDigest: "digest-a",
ArtifactPath: "releases/digest-a",
ValidationStatus: instances.ValidationValid,
}}, &fakeCanvasEditArtifactStore{files: map[string][]byte{
"manifest.yaml": []byte("manifest"),
"index.html": []byte("index"),
}}
}
func stringPointer(value string) *string { return &value }
@@ -478,12 +478,13 @@ type FeaturesConfig struct {
// OfficeSessionIdentity keys an Office task's session identity on the run's
// own agent instead of the task's runner seat, and binds an agent's
// decision re-evaluation to its own calling session instead of the task's
// most-recently-started session. Off in every embedded profile because it
// is an experimental, high-risk, path-scoped change to durable session
// identity and existing session rows are not migrated. A live
// most-recently-started session. On in every embedded profile; it remains a
// high-risk, path-scoped change to durable session identity. A live
// (task_id, agent_profile_id) pair is guarded in-transaction on the office
// session creation path, not by a table-level constraint, and pre-existing
// duplicate rows are deliberately retained and resolved by selection.
// duplicate rows stay safe by selection, not migration. The toggle remains a
// kill switch that restores runner-seat binding and task-active-session
// decision re-evaluation when disabled.
OfficeSessionIdentity bool `mapstructure:"office_session_identity" json:"officeSessionIdentity"`
}
+12 -10
View File
@@ -92,17 +92,19 @@ features:
dev: "false"
e2e: "false"
# High-risk Office per-agent session identity experiment: keys an Office
# task's session on the run's own agent instead of the runner seat. Off in
# every profile because it is an experimental, high-risk, path-scoped change
# to durable session identity and existing session rows are not migrated. A
# live (task_id, agent_profile_id) pair is guarded in-transaction on the
# office session creation path instead of by a table-level constraint.
# Pre-existing duplicate rows are retained and resolved by selection.
# High-risk Office per-agent session identity: keys an Office task's session
# on the run's own agent instead of the runner seat, and binds an agent's
# decision re-evaluation to its own calling session. Default-on in every
# profile; it remains a high-risk, path-scoped change to durable session
# identity. A live (task_id, agent_profile_id) pair is guarded in-transaction
# on the office session creation path instead of by a table-level constraint.
# Pre-existing duplicate rows stay safe by selection, not migration. Disabling
# this toggle and restarting reverts to runner-seat binding and task-active-
# session decision re-evaluation.
KANDEV_FEATURES_OFFICE_SESSION_IDENTITY:
prod: "false"
dev: "false"
e2e: "false"
prod: "true"
dev: "true"
e2e: "true"
# ── Mock providers ────────────────────────────────────────────────
# Swap real third-party services for in-memory fakes. Production
@@ -92,6 +92,53 @@ func TestCanvasFeatureFlagIsDisabledInEveryProfile(t *testing.T) {
}
}
// TestOfficeSessionIdentityFeatureFlagIsEnabledInEveryProfile pins
// AC-OFFICE-IDENTITY-GRADUATION-004.1 and -004.2: the default-on release
// ships "true" for KANDEV_FEATURES_OFFICE_SESSION_IDENTITY in prod, dev and
// e2e alike, flipping together rather than only in prod.
func TestOfficeSessionIdentityFeatureFlagIsEnabledInEveryProfile(t *testing.T) {
for _, profile := range []struct {
name string
selector map[string]string
}{{
name: "prod",
}, {
name: "dev",
selector: map[string]string{
"KANDEV_DEBUG_DEV_MODE": "true",
},
}, {
name: "e2e",
selector: map[string]string{
"KANDEV_E2E_MOCK": "true",
},
}} {
t.Run(profile.name, func(t *testing.T) {
clearProfileSelectors(t)
clearProfilesYAMLVars(t)
for key, value := range profile.selector {
t.Setenv(key, value)
}
defaults, err := EnvironmentDefaults()
if err != nil {
t.Fatalf("EnvironmentDefaults: %v", err)
}
if got := defaults["KANDEV_FEATURES_OFFICE_SESSION_IDENTITY"]; got != "true" {
t.Fatalf("KANDEV_FEATURES_OFFICE_SESSION_IDENTITY = %q in %s, want true", got, profile.name)
}
featureDefaults, err := FeatureFlagDefaults()
if err != nil {
t.Fatalf("FeatureFlagDefaults: %v", err)
}
if got := featureDefaults["office_session_identity"]; got != "true" {
t.Fatalf("office_session_identity = %q in %s, want true", got, profile.name)
}
})
}
}
func TestEnvironmentDefaultsSelectsProfileWithoutMutatingEnvironment(t *testing.T) {
clearProfileSelectors(t)
clearProfilesYAMLVars(t)
@@ -0,0 +1,71 @@
package runtimeflags
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestOfficeSessionIdentityDoesNotClaimUniqueIndexPrecondition pins
// AC-OFFICE-IDENTITY-GRADUATION-003.7: no operator-visible description of
// features.officeSessionIdentity may state that an unshipped
// (task_id, agent_profile_id) unique index is a precondition for enabling
// it. That precondition was dropped in favor of selection-only safety
// (REQ-OFFICE-IDENTITY-GRADUATION-003); a reintroduced claim would tell
// operators to wait on work that will never ship.
func TestOfficeSessionIdentityDoesNotClaimUniqueIndexPrecondition(t *testing.T) {
def, ok := DefinitionByKey("features.officeSessionIdentity")
if !ok {
t.Fatal("features.officeSessionIdentity definition missing")
}
assertNoUniqueIndexClaim(t, "runtime flag registry RiskDescription", def.RiskDescription)
repoRoot := officeSessionIdentityRepoRoot(t)
for _, relPath := range []struct {
path string
locator string
}{
{path: "apps/backend/internal/profiles/profiles.yaml", locator: "KANDEV_FEATURES_OFFICE_SESSION_IDENTITY"},
{path: "docs/public/configuration.md", locator: "features.officeSessionIdentity"},
{path: "docs/public/operations.md", locator: "Office session identity"},
} {
content, err := os.ReadFile(filepath.Join(repoRoot, relPath.path))
if err != nil {
t.Fatalf("read %s: %v", relPath.path, err)
}
assertNoUniqueIndexClaimInSection(t, relPath.path, string(content), relPath.locator)
}
}
func assertNoUniqueIndexClaim(t *testing.T, surface, content string) {
t.Helper()
if strings.Contains(strings.ToLower(content), "unique index") ||
strings.Contains(strings.ToLower(content), "unique-index") {
t.Fatalf("%s still claims a unique-index precondition for features.officeSessionIdentity", surface)
}
}
func assertNoUniqueIndexClaimInSection(t *testing.T, surface, content, locator string) {
t.Helper()
lowerContent := strings.ToLower(content)
index := strings.Index(lowerContent, strings.ToLower(locator))
if index < 0 {
return
}
start := max(0, index-100)
end := min(len(content), index+600)
assertNoUniqueIndexClaim(t, surface, content[start:end])
}
func officeSessionIdentityRepoRoot(t *testing.T) string {
t.Helper()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
// sourceFile: apps/backend/internal/runtimeflags/<this file> -> repo root
// is four directories up.
return filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "../../../.."))
}
@@ -169,7 +169,8 @@ var registrations = []runtimeFlagRegistration{
RiskLevel: RiskHigh,
RiskDescription: "Changes durable Office session identity: each participant agent gets its own session per task instead of sharing the runner's, and existing session rows are not migrated. " +
"A live (task_id, agent_profile_id) pair is guarded in-transaction on the office session creation path, not by a table-level constraint; pre-existing duplicate rows are deliberately retained and resolved by selection rather than repaired. " +
"The guard relies on SQLite's process-local single-writer pool or PostgreSQL's database task-row lock. Two Kandev processes must not write the same SQLite file.",
"The guard relies on SQLite's process-local single-writer pool or PostgreSQL's database task-row lock. Two Kandev processes must not write the same SQLite file. " +
"Disabling this toggle and restarting reverts to runner-seat binding and task-active-session decision re-evaluation.",
RestartRequired: true,
Mutable: true,
},
+8 -7
View File
@@ -498,7 +498,7 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym
| `features.multiTenancy` | `KANDEV_FEATURES_MULTI_TENANCY` | off | Experimental organizations above authenticated users. Requires `features.auth`; startup is refused otherwise. |
| `features.dynamicAgentRouting` | `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` | off | Experimental dynamic profiles with ordered provider-error fallback. |
| `features.canvases` | `KANDEV_FEATURES_CANVASES` | off | Experimental agent-authored isolated web-app canvases for tasks and workspaces. High risk. |
| `features.officeSessionIdentity` | `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` | off | Experimental Office participant sessions. On the Office session creation path, the live `(task_id, agent_profile_id)` pair is guarded in-transaction, not by a table-level index. Pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. |
| `features.officeSessionIdentity` | `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` | on | Office participant sessions: each participant agent gets its own session per task. The live `(task_id, agent_profile_id)` pair is guarded in-transaction, not by a table-level index. Pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. |
The `KANDEV_FEATURES_*` values have no canonical YAML keys. They are selected
@@ -516,12 +516,13 @@ The database can contain canvas migrations, but Kandev does not read or change
canvas data while the flag is off. See [Agent-authored Canvases](canvases.md)
for the experimental user workflow.
For a risky release feature, keep the flag off in the shipped profiles, enable it
only on a selected install through an admin override or explicit environment,
restart, and test it there. Promote the `prod` profile default only after the
feature is ready for everyone; keep the registry entry as a kill-switch until
the rollout is complete, then remove the live flag and move its key and
environment variable to the runtime registry's append-only retired identities.
For a risky release feature that has not graduated, keep the flag off in the
shipped profiles, enable it only on a selected install through an admin override
or explicit environment, restart, and test it there. Promote the `prod` profile
default only after the feature is ready for everyone. Keep the registry entry as
a kill-switch until the planned retirement release, then remove the live flag and
move its key and environment variable to the runtime registry's append-only
retired identities.
Plugins are part of the base product and are not a runtime toggle.
The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; authentication, organizations, and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment.
+1 -1
View File
@@ -555,7 +555,7 @@ Kandev warns when its live WebSocket connection has not recovered for three seco
**Settings > System > Feature Toggles** currently exposes:
- **Office mode**: experimental, medium risk, and off in the production profile by default.
- **Office session identity**: experimental, high risk, and off in every profile by default. The live `(task_id, agent_profile_id)` pair is guarded in-transaction on the office session creation path, not by a table-level index; pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. It gives each Office participant a separate task conversation and requires a restart.
- **Office session identity**: experimental, high risk, and on in every profile by default. The live `(task_id, agent_profile_id)` pair is guarded in-transaction on the Office session creation path, not by a table-level index; pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. It gives each Office participant a separate task conversation and requires a restart. Disabling the toggle restores the pre-graduation runner-seat binding and task-active-session decision re-evaluation.
- **App status bar**: stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins. Urgent WebSocket connectivity warnings still remain visible while the feature is off.
- **Claude background prompt handoff**: experimental, high risk, and off in every profile by default. Enabling it lets Claude Code accept another prompt after its foreground yields while recognized async subagent, `run_in_background` shell, or Monitor work remains active. ACP lifecycle gaps can misclassify activity or overlap prompts; use it only for controlled testing.
- **Unread divider**: a per-user setting at **Settings > General > Task Actions**. It defaults off, takes effect immediately, and controls both the Slack-style **New** divider and read-cursor updates while that user's transcript view is visible.
+2
View File
@@ -59,7 +59,9 @@ dashboard projections, and Office testing contracts.
- [Office Agent Runtime — Error Handling Contract](requirements/runtime.md)
- [Office Scheduler](requirements/scheduler.md)
- [Office Stall Visibility](requirements/stall-visibility.md)
- [Office Session Identity Graduation](requirements/session-identity-graduation.md)
- [Step Entry Sequence Execution](requirements/step-entry-sequence-execution.md)
- [Office Task Session Identity](requirements/task-session-identity.md)
- [Office Tasks](requirements/tasks.md)
- [Office: E2E Mock Harness for Task Sessions and Messages](requirements/testing.md)
- [Office: Slack-Style Unread Divider](requirements/unread-divider.md)
@@ -0,0 +1,338 @@
---
status: draft
system: office
created: 2026-09-07
owners:
- kandev
---
# Office Session Identity Graduation Requirements
## Overview
`features.officeSessionIdentity` decides whose session an Office run binds to.
With it off, a reviewer's or approver's run binds to the task's *runner seat*, so
its verdict is recorded under an identity that does not own the seats the step is
guarded on, the guard counts zero decisions, and Review reports **"Stuck —
Blocked: no active session to re-evaluate this step."** With it on, each
participant agent gets its own durable session per task and multi-agent Office
completes autonomously. The flag is therefore the difference between multi-agent
review working and deadlocking. This capability makes the on-behaviour
unconditional, then retires the identity.
Office owns this contract because it owns the notion that a `(task, agent)` pair
names one durable conversation. The platform owns the toggle machinery
(`REQ-PLATFORM-FEATURE-TOGGLES-001`); the task system owns `task_sessions` and
every non-Office creation path. This document changes neither, and it references
the Tasks-owned re-evaluation rule below.
## Terminology
- **Runner seat** — the agent profile on the task as its assignee
(`AssigneeAgentProfileID`): the agent that does the task's work.
- **Participant identity** — the agent profile of the run being started, captured
by the scheduler before step and routing overrides can mutate it. A reviewer or
approver run's differs from the runner seat.
- **Pair** — a `(task_id, agent_profile_id)` tuple with non-empty
`agent_profile_id`.
- **Terminal state** — `COMPLETED`, `FAILED`, `CANCELLED`. **Live** is its
complement, not an enumerated list.
- **Active state** — `CREATED`, `STARTING`, `RUNNING`, `WAITING_FOR_INPUT`: the
set `GetActiveTaskSessionByTaskID` and decider-session resolution both use.
`IDLE` is in neither this set nor *Terminal*, so an Office session between turns
is unresolvable for re-evaluation though not terminal.
- **Default-on release** — the first release whose shipped `prod` profile value
for this flag is `"true"`.
- **Retirement release** — the release in which the live flag is removed and its
identity appended to the append-only retired-identity set.
## Prior art
**Both outside legs could not run**, recorded rather than faked: no `wiki-query`
vault on this runner, no `search_fsm_docs` tool. The predecessor spec
[`task-session-identity.md`](task-session-identity.md) ran the saas-kb leg on
2026-08-31 and found no vendor documenting per-agent session identity; receipts
are in the task plan.
**In-repo prior decisions, load-bearing:**
- [`task-session-identity.md`](task-session-identity.md)
(`REQ-OFFICE-SESSION-IDENTITY-001` … `-004`) **explicitly rejects** a unique
index on `task_sessions(task_id, agent_profile_id)`:
`AC-OFFICE-SESSION-IDENTITY-004.1` forbids enforcing the invariant "through any
table-level database constraint", `-004.5` forbids any new index, column,
backfill or repair of `task_sessions`, and it records that a previous attempt at
that index broke three shipped flows.
- `AC-OFFICE-TASKS-001.2` asserted one session per `(task, agent)` pair before
either document existed; where they could drift, `tasks.md` wins.
- **ADR 0005** made `agent_profile_id` shared between kanban and Office, so the
pair cannot be constrained at schema level: no discriminator lets an index apply
to Office rows only.
**Divergence from the card.** The card names a `UNIQUE(task_id,
agent_profile_id)` index plus a dedup migration as the blocking precondition. It
is obsolete: both halves of the hazard were closed on 2026-09-01 by a different
mechanism. Duplicate live rows are prevented by an in-transaction guard on the
Office creation path only (`REQ-OFFICE-SESSION-IDENTITY-001`), not a constraint,
so kanban relaunch and workflow replacement keep working; stranding is closed by
the live-preferring lookup (`REQ-OFFICE-SESSION-IDENTITY-002`). The precondition
is **already satisfied, by selection rather than repair**; adding the index would
regress `AC-OFFICE-SESSION-IDENTITY-004`.
**Session-selection contract — Tasks owns the shared rule.**
[`workflow-quorum-decision-recording-reevaluation.md`](../../tasks/requirements/workflow-quorum-decision-recording-reevaluation.md)
`AC-TASKS-QUORUM-REEVALUATION-001.1`/`-001.4` own re-evaluation session
selection for every decision surface. A decision with a validated calling
session uses that session. A decision without a calling session uses
`GetActiveTaskSessionByTaskID`: the task's most recently started session in an
active state, ordered by `started_at` descending, limit one. This document adds
Office-specific acceptance criteria and does not supersede the shared Tasks
contract.
## Requirements
**Release staging — how to read REQ-001 and REQ-002.** REQ-004 stages this
capability across two releases. REQ-001 and REQ-002 state the **end state**: they
bind **as of the retirement release**. In the default-on release the same outcomes
hold while the toggle is on, with the off path governed by
`AC-OFFICE-IDENTITY-GRADUATION-001.7` and `-002.7`; nothing in REQ-001 or REQ-002
requires removing the conditional early. REQ-003 binds in both.
**This capability's implementation delivers the default-on release only**:
`AC-OFFICE-IDENTITY-GRADUATION-004.1`, `-004.2`, `-004.3` and all of REQ-003. The
retirement criteria (`-004.5` … `-004.10`) define the successor release and are
not built here; `-004.4` forbids delivering both at once.
### REQ-OFFICE-IDENTITY-GRADUATION-001: Per-agent session binding is unconditional
**Intent:** An Office run binds to its own agent's session, for every
installation, with no configuration that can restore runner-seat binding.
**User story:** As an operator running multi-agent Office review, I want each
participant's run to use that agent's own session, so a reviewer's verdict counts
against the seats it occupies instead of deadlocking the step.
#### Acceptance criteria
- **AC-OFFICE-IDENTITY-GRADUATION-001.1:** When a run is started for an Office
task and the run carries a participant identity, the system shall bind that
run's session to the participant identity, whatever the task's runner seat is.
As of the retirement release it shall reach that outcome with no dependence on
any profile default, environment variable, or stored installation override.
- **AC-OFFICE-IDENTITY-GRADUATION-001.2:** While a run on an Office task carries
no participant identity and the task has a runner seat, the system shall bind
the run's session to the runner seat. Removing the toggle shall not change this
case: both positions already produce it.
- **AC-OFFICE-IDENTITY-GRADUATION-001.3:** While an Office task has no runner
seat yet, the system shall bind the run's session to the participant identity
when the run carries one, and otherwise to the run's resolved execution
profile identity. While at least one of those two identities is non-empty, the
system shall not fall back to a generic workspace session. While both are
empty, the system shall prepare a session by the pre-existing per-launch path,
adding no new error for it.
- **AC-OFFICE-IDENTITY-GRADUATION-001.4:** When the task is not Office-owned,
the system shall prepare a session by the pre-existing per-launch path,
unchanged.
- **AC-OFFICE-IDENTITY-GRADUATION-001.5:** The system shall leave the Office
find-or-create contract — `REQ-OFFICE-SESSION-IDENTITY-001` through `-003`,
covering the creation guard, the live-preferring lookup and its `started_at` /
`id` tiebreak, and convergence with bounded retry under concurrency —
unchanged. This capability shall add no session-creation path,
no lookup ordering, no reuse rule, and no coordination point.
- **AC-OFFICE-IDENTITY-GRADUATION-001.6:** When two runs for the same pair are
started concurrently, the system shall behave exactly as
`AC-OFFICE-SESSION-IDENTITY-003.1` already requires. It shall not change what
happens when two callers contend for the same pair.
- **AC-OFFICE-IDENTITY-GRADUATION-001.7:** During the default-on release, while
the toggle is off and a run on an Office task carries a participant identity
that differs from the task's runner seat, the system shall bind the run's
session to the runner seat. This is the pre-graduation behaviour the kill
switch restores and the observable difference between the toggle's two
positions. It shall become unreachable at the retirement release.
### REQ-OFFICE-IDENTITY-GRADUATION-002: Decisions re-evaluate against the decider's own session
**Intent:** An agent's recorded decision is checked against the seats its own
calling session occupies, so a reviewer whose agent is not the runner can move
a step.
#### Acceptance criteria
- **AC-OFFICE-IDENTITY-GRADUATION-002.1:** When an agent records a decision
through the agent-decision tool, the system shall re-evaluate the step against
the decider's own calling session. As of the retirement release this holds with
no dependence on the toggle; during the default-on release it holds while the
toggle is on, with `AC-OFFICE-IDENTITY-GRADUATION-002.7` governing the off
path.
- **AC-OFFICE-IDENTITY-GRADUATION-002.2:** As of the retirement release, the
system shall not resolve an agent decision against the task's
most-recently-started session. That fallback shall be unreachable for agent
decisions, enforced **at the agent-decision tool boundary**: the system shall
reject a call supplying no calling session, or one whose session does not exist
or belongs to another task, and write no decision row for it, rather than
silently selecting a session.
- **AC-OFFICE-IDENTITY-GRADUATION-002.3:** **At the re-evaluation resolution
step**, once a decision has been accepted, while the supplied calling session
is unresolvable — absent, belonging to a different task, or not in an
*active state* — the system shall record the decision, skip re-evaluation, and
not reject it or error for that reason. It shall keep all three cases
defensively: `AC-OFFICE-IDENTITY-GRADUATION-002.2`'s boundary already rejects
the first two for agent decisions, and a session bound and active at that
boundary can reach this test unresolvable only by leaving an active state
first. An agent recording through its own live session is active by
construction, so this criterion never contradicts
`AC-OFFICE-IDENTITY-GRADUATION-002.5`. A repository or transport error raised
while resolving is **not** an unresolvable session: the system shall surface it
as an error rather than recording and skipping.
- **AC-OFFICE-IDENTITY-GRADUATION-002.4:** When a decision is recorded by a human
decider rather than an agent, the system shall resolve the task's active session
per `AC-TASKS-QUORUM-REEVALUATION-001.4`, unchanged.
- **AC-OFFICE-IDENTITY-GRADUATION-002.5:** Given an Office task whose reviewer
participant is a different agent from its runner seat, when that reviewer
records a decision satisfying the step's quorum, the system shall transition the
step and not report "no active session to re-evaluate this step".
- **AC-OFFICE-IDENTITY-GRADUATION-002.6:** When the step write succeeds but the
post-write re-evaluation fails, the system shall report success and retain the
recorded decision's identity, and shall not convert a re-evaluation failure into
a write failure.
- **AC-OFFICE-IDENTITY-GRADUATION-002.7:** During the default-on release, while
the toggle is off, the system shall resolve an agent decision against the
task's active session, as it did before graduation. The kill switch shall
revert session binding and decision re-evaluation together; reverting one
without the other is not conformant.
- **AC-OFFICE-IDENTITY-GRADUATION-002.8:** When two participants record decisions
concurrently on one task, the system shall behave as the workflow engine's
existing decision-write and quorum-evaluation contract already requires. It
shall not change the engine's duplicate-write handling, decision ordering, or
which caller owns a resulting transition, and adds no decision write and no new
ordering.
### REQ-OFFICE-IDENTITY-GRADUATION-003: Pre-existing duplicate rows stay safe by default
**Intent:** Turning identity separation on everywhere must not expose the
duplicate `(task_id, agent_profile_id)` rows already in shipped databases, nor fix
them by a mechanism that breaks non-Office flows that deliberately create
duplicates.
#### Acceptance criteria
- **AC-OFFICE-IDENTITY-GRADUATION-003.1:** Given a pair holding one live row and
one or more terminal rows, when an Office session is requested for that pair,
the system shall return the live row and shall not fail.
- **AC-OFFICE-IDENTITY-GRADUATION-003.2:** Given a pair holding more than one
live row written before the guard shipped, when an Office session is requested
for that pair, the system shall return exactly one of those rows and shall not
fail. The row returned shall be determined by the live-preferring order
incorporated at `AC-OFFICE-IDENTITY-GRADUATION-001.5` — `started_at` descending,
then `id` descending as a total tiebreak — so repeated requests against
unchanged data return the same row.
- **AC-OFFICE-IDENTITY-GRADUATION-003.3:** The system shall satisfy
`AC-OFFICE-IDENTITY-GRADUATION-003.1` and `.2` by selection alone: it shall run
no dedup or repair migration, and shall not insert, merge, cancel or delete any
`task_sessions` row or move a `metadata.acp_session_id` between rows. The reuse
writes `AC-OFFICE-SESSION-IDENTITY-001.9` permits on the single row a lookup
returns — `IDLE` to `RUNNING`, and execution-profile rebinding including
clearing that row's own `metadata.acp_session_id` — are preserved by
`AC-OFFICE-IDENTITY-GRADUATION-001.5` and are not forbidden here.
- **AC-OFFICE-IDENTITY-GRADUATION-003.4:** The system shall introduce no unique
index and no other table-level constraint on `task_sessions`, and no new
column on `task_sessions`, in service of this capability. This restates the
binding obligation of `AC-OFFICE-SESSION-IDENTITY-004.1` and `-004.5` where it
is most likely to be violated.
- **AC-OFFICE-IDENTITY-GRADUATION-003.5:** When a kanban task is relaunched for
an agent profile whose session for that task is still live, the system shall
create a second, distinct live session and succeed, with participant binding on
by default.
- **AC-OFFICE-IDENTITY-GRADUATION-003.6:** When a workflow replacement session is
prepared for an existing environment while the current session for the same
pair is live, the system shall create the replacement and succeed, with
participant binding on by default.
- **AC-OFFICE-IDENTITY-GRADUATION-003.7:** No operator-visible description of
this flag shall state that an unshipped `(task_id, agent_profile_id)` unique
index is a precondition for enabling it, and none shall state a default that
contradicts the shipped profile values. This obligation is to the whole set of
operator-visible surfaces describing the flag, not to an enumeration; at the
time of writing: shipped profile metadata, the runtime flag registry's risk
description, the public configuration reference, and the public operations
reference. It applies for as long as the flag remains present.
### REQ-OFFICE-IDENTITY-GRADUATION-004: Staged graduation, then identity retirement
**Intent:** The default flips first and keeps a working kill switch for one
release; only after that does the flag identity disappear, permanently.
#### Acceptance criteria
- **AC-OFFICE-IDENTITY-GRADUATION-004.1:** In the default-on release, the shipped
`prod` profile value for `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` shall be
`"true"`.
- **AC-OFFICE-IDENTITY-GRADUATION-004.2:** In the default-on release, the
shipped `dev` and `e2e` profile values shall also be `"true"`, flipping with
`prod` in the same release.
- **AC-OFFICE-IDENTITY-GRADUATION-004.3:** In the default-on release, the toggle
shall remain listed at **Settings > System > Feature Toggles**, and an
administrator who sets it off and restarts shall observe runner-seat binding
restored per `AC-OFFICE-IDENTITY-GRADUATION-001.7` **and** decision
re-evaluation restored to the task's active session per
`AC-OFFICE-IDENTITY-GRADUATION-002.7`. The kill switch shall be operable, not
merely present.
- **AC-OFFICE-IDENTITY-GRADUATION-004.4:** The system shall not remove the live
flag in the same release that first makes it default-on. The default-on
release and the retirement release shall be distinct releases.
- **AC-OFFICE-IDENTITY-GRADUATION-004.5:** In the retirement release, the exact
key `features.officeSessionIdentity` and the exact environment variable
`KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` shall be present in the append-only
retired-identity set, and shall fail the generic active-reuse check. Neither
identity shall ever be bound to a different capability.
- **AC-OFFICE-IDENTITY-GRADUATION-004.6:** In the retirement release, a stored
installation override row for the retired key shall be inert: it shall produce
no active runtime state, shall not change behaviour, and shall not be deleted.
- **AC-OFFICE-IDENTITY-GRADUATION-004.7:** In the retirement release, setting the
retired environment variable shall produce no active runtime state, shall not
change behaviour, and shall not prevent startup.
- **AC-OFFICE-IDENTITY-GRADUATION-004.8:** In the retirement release, the retired
key shall be absent from the `/api/v1/features` response body, from the
runtime flag definitions the Feature Toggles page reads, and from every
shipped profile. Frontend consumers observe absence as `false` by the
fail-closed rule in `AC-PLATFORM-FEATURE-TOGGLES-001.8`; this adds no new
frontend behaviour.
- **AC-OFFICE-IDENTITY-GRADUATION-004.9:** In the retirement release, every
behaviour required by `REQ-OFFICE-IDENTITY-GRADUATION-001`, `-002` and `-003`
shall retain at least one test that does not reference the toggle, so removing
the flag's own enabled/disabled tests does not reduce behavioural coverage.
- **AC-OFFICE-IDENTITY-GRADUATION-004.10:** In the retirement release, no
startup-configuration surface shall continue to describe the retired
environment variable as an active runtime feature flag, and any audited
inventory fixture mirroring that surface shall agree with it. Retiring the
identity in the flag registry alone, while a configuration catalog still
classifies the variable as live, is not conformant.
## Out of scope
- **A unique index or dedup migration on `task_sessions`.** Requested by the card,
rejected per *Prior art*. The blocker is **discharged, not deferred**: nothing
left to schedule. `AC-OFFICE-IDENTITY-GRADUATION-003.4` makes the exclusion
binding, not advisory.
- **Repairing the existing duplicate pairs.** No count is load-bearing: the census
drifts while the system runs, and `-003.3` forbids repair at any count. The
card's 2026-09-06 measurements motivate this document; no criterion depends on
them.
- **Coupling `features.officeSessionIdentity` to `features.office`.** Rejected: it
keeps two identities alive forever. The end state is one flag fewer.
- **When the retirement release happens.** Only that it be later than the
default-on one (`-004.4`); bake duration and cadence belong to the release
process, not this contract.
- **Cross-process concurrency on one database file**, and **convergence of
concurrent recoveries carrying different execution profiles** (last-writer-wins).
Both inherited unchanged from `task-session-identity.md`.
- **Any new user-visible surface.** The default-on release updates existing
Feature Toggles and public configuration entries. The later retirement release
removes those entries. No new copy, no i18n work, no route gate.
- **The stale DDL in `../system-design/tasks-01.md`.** Editorial, separate
change.
## System design
No paired system design is required: this capability removes a conditional and
retires an identity, adding no new component, model, contract or control flow. It
preserves [`task-session-identity-01.md`](../system-design/task-session-identity-01.md).
@@ -32,19 +32,25 @@ Recording a verdict causes the current step's guarded transitions to be evaluate
- **AC-TASKS-QUORUM-REEVALUATION-001.1:** WHEN a decision is recorded through any surface, THE SYSTEM SHALL
re-evaluate the current step's `on_turn_complete` guarded transitions before
returning to the caller, using the task's active session as defined in AC-TASKS-QUORUM-REEVALUATION-001.4
— including on the human HTTP path, which has no calling session of its own.
returning to the caller, using the re-evaluation session selected by
AC-TASKS-QUORUM-REEVALUATION-001.4.
- **AC-TASKS-QUORUM-REEVALUATION-001.2:** WHEN that re-evaluation finds a satisfied guard, THE SYSTEM SHALL
apply the transition, and the recording call SHALL still report success.
- **AC-TASKS-QUORUM-REEVALUATION-001.3:** WHEN that re-evaluation fails or errors, THE SYSTEM SHALL keep the
recorded decision, report the recording as successful, and surface the
re-evaluation failure through the observability required by AC-TASKS-QUORUM-DIAGNOSTICS-001.2.
- **AC-TASKS-QUORUM-REEVALUATION-001.4:** THE SYSTEM SHALL resolve the re-evaluation session as
`GetActiveTaskSessionByTaskID` already defines it
(`task/repository/sqlite/session.go`): the task's most recently started
session whose state is one of `CREATED`, `STARTING`, `RUNNING`, or
`WAITING_FOR_INPUT`, ordered by `started_at` descending, limit one. A task is
"unresolvable" for this purpose exactly when that query returns no row.
- **AC-TASKS-QUORUM-REEVALUATION-001.4:** THE SYSTEM SHALL resolve the
re-evaluation session as follows:
- When the decision supplies a validated calling session, THE SYSTEM SHALL use
that calling session.
- When the decision supplies no calling session, THE SYSTEM SHALL use
`GetActiveTaskSessionByTaskID` as defined in
`task/repository/sqlite/session.go`: the task's most recently started session
whose state is one of `CREATED`, `STARTING`, `RUNNING`, or `WAITING_FOR_INPUT`,
ordered by `started_at` descending, limit one.
A task is "unresolvable" for this purpose exactly when the selected lookup
returns no row.
- **AC-TASKS-QUORUM-REEVALUATION-001.5:** WHEN no session is resolvable under AC-TASKS-QUORUM-REEVALUATION-001.4, THE SYSTEM SHALL record
the decision and skip re-evaluation without erroring, matching the existing
blank-session behavior in `Engine.RecordParticipantDecision`, and SHALL report
@@ -235,4 +241,4 @@ Recording a verdict causes the current step's guarded transitions to be evaluate
Behavior outside this acceptance-criteria group is defined by the core quorum requirement and the other quorum capability requirements.
Behavior outside this acceptance-criteria group is defined by the core quorum requirement and the other quorum capability requirements.