diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 6fcd6416c9..2c5bfbded2 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -228,6 +228,9 @@ PR after verification. There is no cleanup or status-only PR. - [Draft-aware session inventory](draft-aware-session-inventory.md) — durable, content-free draft/active/unknown activity metadata, server-filtered Chats and Drafts pages, and active-only automatic latest resume. Status: proposed. +- [Session configuration generations](session-configuration-generations.md) - activates explicit + model and effort replacements through one-time exact predecessor edges after target readiness, + backed by bounded fail-visible inventory and presence-aware driver compatibility. Status: proposed. - [Synthetic user-prompt replay](synthetic-user-prompt-replay.md) — emission-time origin metadata for durable user-prompt events, shared genuine-user classification, and mecatui notice rendering for harness-authored replay messages. Status: proposed. diff --git a/docs/acceptance/session-configuration-generations.md b/docs/acceptance/session-configuration-generations.md new file mode 100644 index 0000000000..855cd2d99c --- /dev/null +++ b/docs/acceptance/session-configuration-generations.md @@ -0,0 +1,142 @@ +# Session configuration generations - acceptance plan + +**Contract:** human-reviewed/v2 +**Work classification:** Architectural - this adds explicit replacement intent, two-stage durable exact-predecessor activation, an optional storage paging contract, additive protobuf fields, and a new default inventory projection. +**Decision record:** [ADR 0343](../adr/0343-session-configuration-generations.md) +**Phase:** session continuity and inventory clarity +**Status:** proposed, 2026-09-15. The operator resolved the intent, identity, activation, ambiguous-outcome, bounded-storage, compatibility, and scope decisions interactively. +**Delivery:** Split. Durable engine, snapshot, store, driver, transport, and inventory contracts need separate interface review before implementation. +**Expected tasks:** 4 +**Issue:** [stacklok/mecatl#1490](https://github.com/stacklok/mecatl/issues/1490) +**Plan PR:** [stacklok/mecatl#1557](https://github.com/stacklok/mecatl/pull/1557) +**Approved baseline:** absent until the Plan / Interface PR merges + +A model or reasoning-effort switch explicitly marks its successor as replacing the source. Default +session inventory hides a physical main session only after a retained, owner-visible main-session +target is server-ready and durably activates an exact predecessor edge. Ordinary forks remain +independent even when they change configuration, and clear or worktree-only successors remain +independent. Every physical session remains stored and directly addressable. + +This plan adds one intent field, one optional exact-predecessor reference, and an optional +generation-aware pager. It does not create a Chat aggregate, durable root identity, generation +number, shared cross-session state, mutable head record, readiness field, or repair worker. + +## Human decisions + +None — the operator selected explicit replacement intent, predecessor-only exact references, two-stage fail-visible activation, exact-attempt resolution of ambiguous writes, bounded derived inventory, non-main pass-through, physical-session lifecycle semantics, presence-aware driver compatibility, and legacy physical-inventory fallback; explicit effort reset and client handoff recovery remain in their separately tracked issues. + +## Interface contract + +- **gRPC / protobuf:** `ForkSessionRequest` gains `bool replaces_source = 7`. Mecatui's model and effort switch requests set it; ordinary forks omit it or send false. Public `ListSessionsRequest` gains ordinary `bool include_replaced = 3`; absent and false both request the default head-only view from a generation-capable server. `SessionSummary` gains `bool is_replaced = 19`. HTTP `POST /v1/sessions/{source}/fork` accepts the equivalent `replaces_source` JSON field, and `GET /v1/sessions` accepts the equivalent `include_replaced` query parameter. Driver `SaveRequest` gains `string incarnation = 4`, `string predecessor_session_id = 5`, and `string predecessor_incarnation = 6`, allowing an opaque-snapshot driver to index the saved row's exact identity and optional predecessor. Driver `PageSessionMetadataRequest` gains **presence-aware** `optional bool include_replaced = 6`: absent means legacy physical inventory, present false means head-only, and present true means all physical rows. A new server always sends this field explicitly when calling a driver that advertises `generation_inventory`. `SessionMetadataEntry` gains only `bool is_replaced = 30` for this feature; `SessionStoreCapabilitiesResponse` gains `bool generation_inventory = 7`. The paging response exposes no root or predecessor graph fields. No RPC is added or removed. +- **Exported Go APIs / interfaces:** Add `session.SessionPredecessorRef{ID SessionID, Incarnation IncarnationID}`. The reference is exact identity metadata, not a credential or capability. `Session` stores an optional predecessor privately and gains `Predecessor() *SessionPredecessorRef`, `SetPredecessor(source *Session) error`, and `RestorePredecessor(*SessionPredecessorRef)`. `SetPredecessor` is the one-time activation transition: it accepts only an owner-matching valid main-session source whenever the valid main-session target has no predecessor, including after the target's initial predecessor-free persistence; it derives the reference from `source.ID` and `source.Incarnation()` and rejects replacement or removal after attachment. It never accepts caller-supplied reference fields and no longer requires a pristine, never-saved target. `RestorePredecessor` is restricted to trusted snapshot/storage rehydration and treats nil, zero, or structurally malformed values as no edge so transcript restore remains available. Existing exported `Session` fields remain unchanged. Add `port.SessionGenerationMetadataPageRequest{SessionMetadataPageRequest; IncludeReplaced bool}`, `port.SessionGenerationDiscoveryMeta{SessionDiscoveryMeta; IsReplaced bool}`, and `port.SessionGenerationMetadataPage{Sessions []SessionGenerationDiscoveryMeta; NextCursor *SessionMetadataCursor; TotalCount int}`. Add the optional `port.SessionGenerationMetadataPager`, which embeds `SessionMetadataPager` and declares `SupportsSessionGenerationInventory() bool` plus `PageSessionGenerationMetadata(context.Context, SessionGenerationMetadataPageRequest) (SessionGenerationMetadataPage, error)`. The result exposes no self reference, predecessor, root, or graph. Existing `SessionMetadataPager`, `SessionMetadataPageRequest`, `SessionMetadataPage`, and `SessionDiscoveryMeta` remain unchanged. +- **Tool schemas:** None - replacement grouping is not model-facing and changes no tool. +- **CLI / config:** None - no flag, setting, precedence rule, or key binding is added. Mecatui `/sessions` consumes the server's default inventory and adds no historical view. Its model and effort switch paths set `replaces_source=true`; its ordinary session-fork path leaves the field false. +- **Events / persistence:** `sessnap` persists only optional predecessor ID and incarnation fields and restores malformed or partial values as no edge without blocking transcript load. JSONL, memstore, Redis, and capable remote drivers round-trip the saved row's self incarnation and optional exact predecessor in private projections. A replacement request uses two durable writes and the edge itself as the only activation marker: (1) create and persist the target with no predecessor, so source and target are both visible and independently addressable; (2) complete required server-side target registration, broker, reattachment, and equivalent readiness work; then (3) attach the exact predecessor once and persist the target snapshot and store projection atomically. Only the final persistence activates replacement visibility. Existing preparation that already precedes first persistence may remain there, but the general contract does not rely on that ordering. A crash or failure before activation leaves both rows visible. A failure after proven activation, including response delivery, client handoff, or source close, leaves the server-ready target as head and does not roll the edge back. `Create`/`Save` errors are potentially ambiguous: after an initial or activation write error, the server probes the exact target ID and incarnation and continues only if the authoritative loaded row matches that exact identity and the expected predecessor state (absent initially, exact source on activation). Missing, colliding, or mismatched state fails closed. If storage is unreadable, the attempt stops without another mutation; once storage is readable, the authoritative predecessor state determines whether both rows remain visible or the ready target is the head. The attempt must not blindly mint another target. Cross-request retry idempotency is not added. No event type, durable root, `SessionGeneration`, generation number, replacement-reason enum, readiness field, eager migration, backfill, or reconciliation worker is added. +- **Storage projection / paging:** A generation-capable store maintains a private exact-predecessor and reverse-edge projection plus derived visibility. `Save`, `Delete`, and generation-relevant metadata mutations update snapshot metadata, forward/reverse edges, derived `is_replaced` membership, ordering/ownership indexes, and the existing pager mutation generation atomically, or fail visible. Adding, changing through restore, or removing an edge recomputes the affected undirected component sufficiently to detect cycles; a cycle, inability to derive, or corrupt component marks every affected retained row unreplaced rather than hiding one. Malformed, dangling, incarnation-mismatched, cross-owner, and cross-kind edges hide no row. After owner filtering, grouping applies only to valid main-session rows; every scheduled, delegated, debug, unknown, malformed-kind, or future owner-visible non-main row passes through unchanged and unreplaced. Generation-aware `Page` reads the already-derived bounded projection and must not scan or load every snapshot per page. Scan/reference adapters may derive that projection from their bounded metadata index, never transcripts; indexed adapters choose their own data structure. Remote drivers own the same boundedness and atomic/fail-visible guarantee behind `generation_inventory` negotiation. Filtering and derived visibility precede total count, ordering, cursor construction, and page slicing. The selected `IncludeReplaced` view is bound into cursor scope. +- **Cursor invalidation:** Every `Save`, `Delete`, or metadata mutation capable of changing inventory membership, ordering, ownership, predecessor-edge validity, component validity, or derived `is_replaced` advances the pager's existing mutation generation in the same atomic update. Every outstanding cursor from the prior generation then returns `ErrSessionMetadataCursorRestart`; pagers never continue across a possibly changed projection. Cursor scope also binds `include_replaced`, so changing the requested view returns the same restart error. +- **Security / authority:** Inventory remains owner-filtered and content-free. A predecessor reference is not authenticated by knowledge of its ID/incarnation pair and grants no ownership, continuation, rename, delete, placement, or transcript authority. Public transports accept only `source_session_id` plus replacement intent; they never accept a predecessor incarnation. Both transport paths use the existing `ForkSessionSuccessor` chokepoint, which owner-authorizes the source, reloads and reauthorizes it under the source lock, and derives the predecessor from that loaded aggregate. The target inherits the authorized source owner, and `SetPredecessor` independently rejects non-main or owner-mismatched aggregates. A retained successor can hide only an owner-visible valid main session whose exact ID and incarnation it references after server readiness and atomic activation. Malformed, dangling, incarnation-mismatched, cross-owner, and cross-kind edges hide no row. Cyclic, corrupt, or underivable connected components fail visible. Existing source authorization, source lock/lease, target preparation, and action revalidation remain authoritative. When ownership enforcement is disabled, generation metadata preserves that existing single-tenant compatibility posture and does not claim independent authentication. +- **Compatibility / migration:** Compatibility is mandatory at the wire/schema level, while the new server's default public result intentionally changes to head-only when generation inventory is supported. All protobuf and snapshot fields are additive. Old snapshots and malformed predecessor values load with no edge; old clients ignore `is_replaced`; old servers ignore `replaces_source` and public `include_replaced`. An old public client against a new generation-capable server omits public `include_replaced` and therefore intentionally receives head-only inventory; this is the feature's intended additive behavioral change, not byte-identical result preservation. An old mecatui also omits replacement intent, so its own configuration-carrying forks remain independent. A new client against an old server completes the existing switch but gets physical inventory because intent and view fields are ignored. The existing engine pager structs and interface remain byte-for-byte unchanged. `session_generation_inventory` is advertised only when the active store implements `SessionGenerationMetadataPager` and reports support, or when a remote driver advertises its separate `generation_inventory` capability. With only the legacy pager, both public request values return identical physical pages, totals, and cursors; with neither pager, listing retains `ErrSessionMetadataPagingUnsupported`. New engine identifiers are Added/minor and require `task api:update` plus an `engine/CHANGELOG.md` entry. Rewriting a new snapshot through an older binary can discard unknown predecessor fields; a later new binary exposes that row independently. + +### Compatibility matrix + +| Pairing | Paging selector sent to storage | Inventory result | +|---|---|---| +| Old public client -> new generation-capable server | Public field absent selects head-only; when storage is remote, the server sends the driver field explicitly false | Head-only; `is_replaced` is ignored by the old client. This intentional behavior change is the feature. | +| New public client -> old server | New public fields are ignored | Legacy physical inventory; replacement intent is not recorded. | +| New server -> old/non-capable driver | No generation capability, so no generation paging call | Legacy physical inventory and no public generation capability. | +| Old server -> new driver | Driver `include_replaced` is absent | Legacy physical inventory, preserving the old server's expectations. | +| New server -> new generation-capable driver | Driver field is always present: false for heads, true for all rows | Requested derived view with `is_replaced`; cursors are scoped to the selector. | + +## In scope - 4 scenarios, in implementation order + +### Scenario 1 - explicit replacement intent activates exact durable identity + +Replacement identity uses the existing incarnation boundary from +[ADR 0258](../adr/0258-cryptographic-session-incarnations.md), separate from the trusted +producer taxonomy in [ADR 0217](../adr/0217-session-discovery-continuation.md). + +**Acceptance:** +- AC1.1: `ForkSessionRequest.replaces_source=true` first persists a distinct target with no predecessor. Mecatui's model and effort switch paths set the field. The result is the same when resolved configuration equals the source and when the request also carries a worktree selector. + - verify: `TestADR_0343_ExplicitReplacementIntent` +- AC1.2: An ordinary fork with `replaces_source` omitted or false has no predecessor even when provider, model, reasoning effort, title, or worktree selector differs. Clear and worktree-only successors also have no predecessor. + - verify: `TestSessionConfigurationGenerations_Scenario1_IndependentSuccessorsIgnoreOverrides` +- AC1.3: After initial target persistence, required server-side preparation succeeds before the existing `ForkSessionSuccessor` path reloads and reauthorizes the source under its lock. `SetPredecessor(source)` rejects non-main or owner-mismatched aggregates, derives the exact ID/incarnation from that authorized source rather than wire input, and attaches it once. Atomic persistence of that edge and the derived store projection is the sole activation boundary. A crash or failure before activation leaves both rows visible and directly addressable; a failure after proven activation leaves the prepared target as head. + - verify: `TestSessionConfigurationGenerations_Scenario1_TwoStageActivationIsFailVisible` +- AC1.4: Snapshot and local and remote store conformance proofs round-trip only the optional exact predecessor. Driver Save supplies self incarnation on both writes, no predecessor on initial persistence, and exact predecessor on activation. Legacy, partial, and malformed values become no edge without preventing transcript load. + - verify: `TestSessionConfigurationGenerations_Scenario1_PredecessorMetadataRoundTrips` + +### Scenario 2 - default inventory uses a bounded derived head projection + +The generation-aware pager owns grouping because the existing inventory contract requires +filtering before pagination and counting, as described in +[ADR 0217](../adr/0217-session-discovery-continuation.md). + +**Acceptance:** +- AC2.1: For retained activated main sessions `A -> B -> C`, default inventory returns C and `include_replaced=true` returns all three physical sessions with A and B marked replaced. A predecessor-free prepared target does not hide its source. No durable root, readiness field, or generation number participates. + - verify: `TestSessionConfigurationGenerations_Scenario2_PredecessorChainAndHistoricalView` +- AC2.2: Sibling successors `A -> B` and `A -> C` hide A while B and C remain visible heads. Deleting C leaves B as a head and A hidden; deleting both successors reveals A. For `A -> B -> C`, deleting B makes A and C visible because C's dangling edge is ignored. Deletion removes only the selected physical session and its existing sidecars. + - verify: `TestSessionConfigurationGenerations_Scenario2_SiblingsAndPhysicalDeletion` +- AC2.3: Save/Delete atomically maintain the private exact-predecessor/reverse-edge projection, derived visibility, metadata indexes, and pager generation. Edge addition/removal recomputes the affected undirected component; malformed, dangling, incarnation-mismatched, cross-owner, cross-kind, cyclic, corrupt, or underivable components fail visible without invalidating unrelated components. + - verify: `TestSessionConfigurationGenerations_Scenario2_AtomicProjectionAndCorruptionFailVisible` +- AC2.4: Generation-aware pages read the already-derived bounded metadata projection without loading or scanning every snapshot or any transcript per page. Grouping applies only after ownership filtering to valid main rows; all owner-visible non-main or unrecognized rows pass through unreplaced. Any membership, ordering, ownership, edge-validity, component-validity, or `is_replaced` mutation invalidates old cursors, and cursor scope rejects a different `include_replaced` view. + - verify: `TestSessionConfigurationGenerations_Scenario2_BoundedProjectionAndCursorInvalidation` + +### Scenario 3 - capability negotiation preserves mixed-version meaning + +Generation-aware paging is an optional extension of the compatibility model in +[ADR 0248](../adr/0248-sdk-compatibility-and-error-contract.md). + +**Acceptance:** +- AC3.1: The public server advertises `session_generation_inventory` only when the active store implements `SessionGenerationMetadataPager` and reports support; a remote store reports support only when its driver advertises the separate `generation_inventory` capability. + - verify: `TestSessionConfigurationGenerations_Scenario3_FeatureMatchesPagerCapability` +- AC3.2: A generation-aware pager honors both public inventory views and scopes cursors to the selected view. A store with only the unchanged `SessionMetadataPager` advertises no generation feature and returns identical physical pages, counts, and cursors for both request values. A store with neither pager retains `ErrSessionMetadataPagingUnsupported`. + - verify: `TestSessionConfigurationGenerations_Scenario3_LegacyPagerFallback` +- AC3.3: The full compatibility matrix is proved: new client -> old server yields physical inventory; old public client -> new capable server intentionally yields head-only inventory while old replacement requests remain independent; new server -> old driver yields physical inventory and no feature; old server -> new driver omits the driver selector and receives physical inventory. Transcript loading, exact-ID access, and existing actions remain available in every pairing. + - verify: `TestSessionConfigurationGenerations_Scenario3_MixedVersionCompatibilityMatrix` +- AC3.4: A new server always sends generation-capable driver paging `optional include_replaced` explicitly: false for head-only and true for all physical rows. A generation-aware driver receives self incarnation and optional predecessor on Save and returns `is_replaced` without exposing root or graph fields. Absent driver selector means legacy physical inventory. + - verify: `TestSessionConfigurationGenerations_Scenario3_DriverSelectorPresenceAndProjection` + +### Scenario 4 - publication ambiguity and historical access stay server-authoritative + +Mecatui retains the server-authoritative inventory and transcript boundaries from +[ADR 0217](../adr/0217-session-discovery-continuation.md). + +**Acceptance:** +- AC4.1: After an ambiguous initial persistence error, the server probes the exact target ID/incarnation and continues preparation only when the row matches and has no predecessor. Missing or mismatched/colliding state fails closed, leaves the source visible, and never blindly creates another target for that attempt. + - verify: `TestSessionConfigurationGenerations_Scenario4_AmbiguousInitialPersistIsProbed` +- AC4.2: After an ambiguous activation persistence error, the server probes the same exact target. A matching exact predecessor proves activation and permits target handoff; a proven absent predecessor leaves source and target visible; mismatch or a missing target row fails closed. Unreadable storage stops the attempt without another mutation; after recovery, the authoritative predecessor state determines visibility. No generation projection hides the source unless it contains the committed exact edge for a server-ready target. + - verify: `TestSessionConfigurationGenerations_Scenario4_AmbiguousActivationIsFailVisible` +- AC4.3: Against a generation-aware server, mecatui `/sessions` uses the head-only public default; gRPC and HTTP `include_replaced=true` return the same physical rows and `is_replaced` values. Exact-ID transcript loading reaches hidden predecessors and predecessor-free prepared targets, while rename, delete, fork, lease, event-log, placement, and recovery remain physical-session scoped. + - verify: `TestSessionConfigurationGenerations_Scenario4_HistoricalAPIParityAndExactIDAccess` + +## Out of scope + +| Item | Defer-to | Decision | +|---|---|---| +| Explicit reasoning-effort `auto` versus omitted inheritance | [#1485](https://github.com/stacklok/mecatl/issues/1485) | Presence-aware effort reset is independent of inventory grouping. | +| Effort-switch hydration and source-close failure | [#1486](https://github.com/stacklok/mecatl/issues/1486) | This plan orders required target readiness before activation but does not redesign each preparation step or source close. | +| Conditional chat-head mutation or cross-request retry idempotency | evidence-driven follow-up | Sibling activated successors remain visible heads; this plan resolves ambiguous writes only within the exact target attempt. | +| Chat aggregate, `ChatID`, durable root, `SessionGeneration`, readiness field, shared title, aggregated usage, or family-wide actions | separate product decision | The predecessor edge is both the only relationship metadata and the activation marker. | +| Mecatui historical-generations view | later interaction-design work | The API exposes `include_replaced`; mecatui changes only its default result. | +| Chain repair, reconciliation, eager migration, or backfill | operational evidence | Invalid or legacy predecessor metadata and underivable components fail visible. | +| Chain-aware retention or cascade deletion | separate retention decision | Existing physical-session lifecycle remains authoritative. | +| Broker, lease, rehydration, registration, response-delivery, client-handoff, and source-close redesign | [#1486](https://github.com/stacklok/mecatl/issues/1486) or separate reliability work | Required readiness must precede activation; the internals and handoff recovery remain separate. | +| Backend-specific graph/index data structure | adapter implementation | The atomic, bounded, reverse-edge, component-recompute, and fail-visible behavior is contractual; representation is private. | + +## Definition of done + +1. `task generate`, `task api:update`, `task lint`, `task test`, `task api:check`, `task docs`, and `task site:build` pass. +2. `task ac-trace-strict` resolves every named proof when this plan becomes `landed`. +3. `go run ./cmd/mecademo` remains green. +4. The owning user documentation and living architecture describe explicit replacement intent, two-stage predecessor activation, bounded predecessor-derived head inventory, non-main pass-through, the administrative historical view, and mixed-version fallback. +5. The implementation PR links the Plan / Interface PR and approved commit and reports engine API, snapshot, local-store, driver, transport, ownership, bounded pagination, activation, ambiguous-outcome, cursor-invalidation, and compatibility conformance. +6. `/panel-review` reports no ship blockers or unwaived reviewer failures. + +## Deferred decisions and known risks + +- Concurrent explicit replacements and retries after the exact attempt is abandoned can create sibling heads. Inventory reveals every retained head and does not choose a winner. +- Physical deletion or retention can break an edge and reveal older sessions. Inventory reads never mutate or repair metadata. +- A failure after proven activation can leave a ready target that the initiating client did not receive. Inventory and exact-ID access make the durable target recoverable; automatic handoff recovery is separate. +- A predecessor-free target left by failed preparation remains an independent visible session. This is the deliberate fail-visible cost of not hiding a usable source too early. +- Deployments backed by an older external store or driver keep physical-session inventory until that backend implements the optional bounded pager. +- Indexed stores pay component-recompute work on generation metadata mutations so page reads remain bounded; unusually large connected components can make writes more expensive. diff --git a/docs/adr/0065-conversation-fork.md b/docs/adr/0065-conversation-fork.md index 280fedb1ae..133f4e9687 100644 --- a/docs/adr/0065-conversation-fork.md +++ b/docs/adr/0065-conversation-fork.md @@ -4,7 +4,7 @@ - Date: 2026-07-14 - Scope: a new `ForkSession` wire RPC + HTTP route, a `Service.ForkSession` method in `internal/adapter/server`, reuse of existing domain primitives (`session.ForkSnapshot`/`SeedHistory`/`New`); no `engine/`, `port`, or domain-package change - Supersedes: none -- Superseded by: [ADR 0071](./0071-seamless-model-switch.md) (in part — the same-provider constraint for the *carryover* path; the `ForkSession` RPC itself remains same-provider/model-locked) +- Superseded by: [ADR 0071](./0071-seamless-model-switch.md) (in part — the same-provider constraint for the *carryover* path; the `ForkSession` RPC itself remains same-provider/model-locked); [ADR 0343](./0343-session-configuration-generations.md) only where carryover generation identity was out of scope ## Context diff --git a/docs/adr/0068-effort-change-via-fork.md b/docs/adr/0068-effort-change-via-fork.md index 66cfaae6a2..45b381fa04 100644 --- a/docs/adr/0068-effort-change-via-fork.md +++ b/docs/adr/0068-effort-change-via-fork.md @@ -4,6 +4,7 @@ - Date: 2026-07-21 - Scope: `ForkSession` wire/service gains an optional `reasoning_effort` override; mecatui `/effort` switches effort by fork-resume instead of a transcript-wiping restart - Supersedes: the confirm-overlay interim shipped under issue #246 (superseded in place — that path is removed, not layered) +- Superseded by: [ADR 0343](./0343-session-configuration-generations.md) only for the replacement session's durable generation identity ## Context diff --git a/docs/adr/0071-seamless-model-switch.md b/docs/adr/0071-seamless-model-switch.md index 11bc315e4e..02e2b17668 100644 --- a/docs/adr/0071-seamless-model-switch.md +++ b/docs/adr/0071-seamless-model-switch.md @@ -4,7 +4,7 @@ - Date: 2026-07-23 - Scope: the `/models` picker switch UX (mecatui), the `CreateSession.source_session_id` carryover surface (server), and the new `session.StripProviderState` domain transform (engine/session) - Supersedes: the "Mid-session model switch" + "Same-provider history carryover" decisions in [ADR 0016](./0016-multi-provider.md) (the confirm-overlay UX and the same-provider-only gate), and the "same-provider constraint" note for carryover in [ADR 0065](./0065-conversation-fork.md) -- Superseded by: none +- Superseded by: [ADR 0343](./0343-session-configuration-generations.md) only for the replacement session's durable generation identity ## Context diff --git a/docs/adr/0217-session-discovery-continuation.md b/docs/adr/0217-session-discovery-continuation.md index 2fed73dc20..ae2d3f6ab3 100644 --- a/docs/adr/0217-session-discovery-continuation.md +++ b/docs/adr/0217-session-discovery-continuation.md @@ -3,7 +3,7 @@ - Status: Accepted - Date: 2026-08-14 - Scope: session creation metadata, SessionStore snapshots/meta projections, HarnessService session inventory/transcript surfaces, and mecatui session discovery/continuation -- Superseded by: [ADR 0285](./0285-predictable-mecatui-session-handles.md) (decision 8 only) +- Superseded by: [ADR 0285](./0285-predictable-mecatui-session-handles.md) (decision 8 only); [ADR 0343](./0343-session-configuration-generations.md) (Decision 1 only where model and effort replacements were lineage-free peers) ## Context diff --git a/docs/adr/0343-session-configuration-generations.md b/docs/adr/0343-session-configuration-generations.md new file mode 100644 index 0000000000..9b2fb862b3 --- /dev/null +++ b/docs/adr/0343-session-configuration-generations.md @@ -0,0 +1,213 @@ +# ADR 0343 - Group explicit configuration replacements as session generations + +- Status: Proposed +- Date: 2026-09-15 +- Scope: main-session replacement intent, two-stage durable predecessor activation, bounded session inventory, and compatibility discovery +- Supersedes: ADR 0065 only where carryover lineage was out of scope, ADR 0068 and ADR 0071 only where replacement sessions lacked durable predecessor identity, and ADR 0217 Decision 1 only where model and effort replacements were lineage-free peers +- Superseded by: None + +## Context + +Changing a model or reasoning effort creates a new main session because provider and model +configuration stays fixed for each physical session. The replacement copies the conversation, +so the operator experiences one continuing chat. Session inventory lists both physical sessions +as unrelated chats. + +Configuration overrides do not prove replacement intent. An operator can make an ordinary, +independent conversation fork while also selecting another model, provider, effort, or worktree. +Conversely, a replacement can resolve to the same configuration as its source. The request must +state the distinction. + +The existing `SessionRelationship` describes trusted producer relationships for scheduled, +delegated, and debug sessions. Main sessions deliberately carry no such relationship. Durable +session incarnations already distinguish an exact session lifetime from later reuse of the same +session ID. + +Grouping must happen before inventory counting and pagination. Client-side deduplication would +produce short pages, incorrect totals, and unstable continuation. Indexed stores cannot load or +scan every snapshot on each page, and opaque remote drivers need an explicit compatibility mode. +The design must preserve every non-main inventory row and fail visibly under corrupt graph state. + +A target snapshot can become authoritative before a failed `Create` or `Save` response reaches +the server. Conversely, persisting a replacement edge before server-side registration, broker, +reattachment, or equivalent readiness succeeds would hide a still-usable source in favor of an +unready target. Persistence response success is therefore neither a sufficient readiness marker +nor an unambiguous account of storage state. + +## Decision + +Treat a history-carrying fork as a replacement only when `ForkSessionRequest.replaces_source` is +true. Mecatui sets this field for model and reasoning-effort switches. Ordinary forks omit it or +send false and remain independent, including forks with provider, model, effort, title, or +worktree overrides. Clear and worktree-only successors also remain independent. The resolved +configuration never classifies replacement intent. + +A replacement has one optional exact predecessor reference containing the source session ID and +incarnation. The reference is exact lifetime metadata, not a credential or capability. It has no +durable root, generation number, readiness field, or `SessionGeneration` value. A session without +a valid predecessor is independent for inventory purposes. Legacy, partial, and structurally +malformed predecessor metadata becomes no edge without preventing transcript load. + +Use the predecessor edge itself as the one-time activation marker: + +1. Create and persist the target with no predecessor. Source and target are both visible and + independently addressable. +2. Complete required server-side target registration, broker, reattachment, and equivalent + readiness work. Preparation that already occurs before first persistence may remain there, + but the contract does not rely on that ordering. +3. Attach the source's exact ID and incarnation once, then persist the target snapshot and the + store's generation projection atomically. Only this persistence activates replacement + visibility. + +`Session.SetPredecessor(source)` performs that one-time activation transition whenever the target +has no predecessor, including after initial persistence. It accepts only valid main-session +aggregates with the same owner, derives the reference from `source.ID` and +`source.Incarnation()`, and never accepts caller-supplied reference fields. It rejects replacement +or removal after an edge is attached; it does not require a pristine or never-saved target. +`RestorePredecessor` accepts raw persisted metadata only at the trusted snapshot/storage +rehydration boundary. A crash or failure before activation leaves both physical rows visible. A +failure after proven activation, including response delivery, client handoff, or source closure, +leaves the server-ready target as the head; the operation does not roll the edge back. + +Public transports accept only `source_session_id` plus replacement intent; no caller supplies a +predecessor incarnation. Both gRPC and HTTP converge on the existing `ForkSessionSuccessor` +service path. That path owner-authorizes the source, reloads and reauthorizes it under the source +lock, and gives the loaded aggregate to `SetPredecessor`; the target inherits that source's owner. +Inventory then filters ownership before evaluating edges. Knowledge of another session's ID and +incarnation therefore grants no access and cannot make a cross-owner edge hide either owner's row. +When ownership enforcement is disabled, this feature preserves the existing single-tenant +compatibility posture rather than claiming independent authentication. + +Treat every `Create` or `Save` error as potentially occurring after the snapshot became +authoritative. After an ambiguous initial persistence error, probe the exact target ID and +incarnation and continue only if the authoritative row has that identity and no predecessor. +After an ambiguous activation error, probe the same target and treat activation as committed only +if the row has the expected exact predecessor. A missing, colliding, or mismatched row fails +closed. If storage is unreadable, stop the attempt without another mutation; once storage is +readable, its authoritative predecessor state determines whether both rows remain visible or the +server-ready target is the head. The same attempt must not blindly mint another target. This +resolves one exact attempt only; cross-request retry idempotency remains outside this decision. + +Generation-aware inventory filters ownership first and groups only rows with valid main-session +metadata. It passes every other owner-visible row through unchanged and marks it unreplaced. This +includes scheduled, delegated, debug, unknown legacy, malformed-kind, and future unrecognized +rows. + +For valid main sessions, a retained row is replaced only when another retained, owner-visible, +valid main session names its exact ID and incarnation as predecessor through an activated edge. +Malformed, dangling, incarnation-mismatched, cross-owner, and cross-kind edges hide no row. +Sibling successors are valid: they hide their shared predecessor and remain multiple visible +heads. If otherwise valid edges form a cycle, inventory reveals every row in the affected +undirected connected component, including attached tails and branches. An inability to derive or +corruption in a component likewise leaves every affected row unreplaced. Unrelated components +still derive heads normally. + +Physical deletion never cascades or mutates another snapshot. Deleting a sole head can reveal its +predecessor. Deleting an intermediate row breaks adjacent retained edges as applicable; for +`A -> B -> C`, deleting B leaves A and C visible because C's edge is dangling. + +A generation-capable store maintains a private exact-predecessor and reverse-edge projection plus +derived visibility. `Save`, `Delete`, and generation-relevant metadata mutations update snapshot +metadata, forward and reverse edges, derived `is_replaced` membership, ordering and ownership +indexes, and the existing pager mutation generation atomically, or fail visible. Edge addition or +removal recomputes the affected undirected component sufficiently to detect cycles. This is a +behavioral contract, not a prescribed backend data structure. + +Generation-aware `Page` reads the already-derived bounded projection; it does not scan or load +every snapshot per page. Scan/reference adapters may derive the projection from their bounded +metadata index, never transcripts. Remote drivers own the same boundedness and atomic/fail-visible +guarantee when they advertise generation support. Ownership filtering and derived visibility +precede total count, ordering, cursor construction, and page formation. + +Every `Save`, `Delete`, or metadata mutation capable of changing membership, ordering, ownership, +edge validity, component validity, or `is_replaced` advances the pager's existing mutation +generation in the same atomic update. A cursor from the previous generation returns +`ErrSessionMetadataCursorRestart`. Cursor scope also includes the selected `include_replaced` +view, and changing that view requires restart. + +Generation-aware inventory returns derived heads plus every pass-through row by default. It +returns every retained physical row when public `include_replaced` is true and marks only hidden +valid main rows with `is_replaced`. The public request remains an ordinary bool: absent and false +both mean head-only on a capable server. Thus an old public client against a new capable server +intentionally receives head-only inventory. That additive behavioral change is the purpose of the +feature; compatibility does not promise byte-identical list results. + +The existing `SessionMetadataPager` remains unchanged. A new optional +`SessionGenerationMetadataPager` returns the existing `SessionDiscoveryMeta` projection plus +`IsReplaced`; it exposes no self reference, predecessor, root, or graph. A server advertises the +public `session_generation_inventory` feature only when its active store supports this optional +pager. A remote store depends on the driver's separate `generation_inventory` capability. + +Driver Save supplies self incarnation and optional predecessor ID/incarnation. Driver paging adds +presence-aware `optional bool include_replaced`: absent means legacy physical inventory, present +false means head-only, and present true means all physical rows. A new server always sends the +field explicitly when invoking a generation-capable driver. This makes all pairings coherent: + +- old server -> new driver omits the field and receives legacy physical inventory; +- new server -> old or non-capable driver uses legacy paging, exposes physical inventory, and + omits the public generation feature; +- new server -> new capable driver sends false for heads or true for all physical rows; +- new client -> old server has new request fields ignored and receives physical inventory; +- old public client -> new capable server receives the intended head-only default and ignores + `is_replaced`; its omitted `replaces_source` also keeps its own forks independent. + +Deployments with only the legacy pager return physical inventory for either public request value. +Deployments with neither pager retain the existing unsupported error. Older binaries can discard +predecessor metadata when rewriting a snapshot; a newer binary then exposes that row +independently. + +Generation grouping creates no shared chat state. Each physical session retains its own +lifecycle, title, token usage, event log, permissions, placement, exact-ID access, rename, and +deletion. + +## Consequences + +Default session inventory matches the operator's continuing-chat model only after the initiating +path explicitly declares replacement intent and the target is server-ready. Independent forks do +not group merely because they carry configuration overrides. + +Predecessor-only metadata avoids durable root identity and root-consistency failure modes. The +same edge doubles as activation, so no readiness field or mutable head record is needed. The +cost is two durable writes for replacement creation and a visible predecessor-free target when +preparation fails. + +Ambiguous storage responses require exact-ID/incarnation probes. Matching predecessor state lets +the same attempt continue safely; missing or mismatched state fails closed. This prevents blind +duplicate creation within an attempt but does not provide an idempotency protocol across retries. + +The storage layer gains an optional paging capability, private forward/reverse-edge metadata, and +derived visibility. Writes may pay recomputation cost for an affected connected component so page +reads stay bounded. Cycles, corruption, and derivation failures expose rows rather than hiding +them. The public and engine page results remain bounded and do not expose the graph. + +All inventory-affecting writes invalidate outstanding cursors through the existing pager mutation +generation. Clients may restart more often, but they never continue across changed membership, +ordering, ownership, edge validity, or visibility. + +Mixed-version wire compatibility is additive but list results are deliberately not byte-identical +for old public clients against a new capable server: omission means the new head-only default. +Presence awareness exists only on the driver projection, where omission must preserve an old +server's expectation of physical inventory. + +Concurrent activated replacements and retries after an exact attempt is abandoned can produce +more than one visible head. Preventing siblings would require conditional head mutation and a +cross-request idempotency protocol, which are outside this decision. + +A failure after proven activation can leave a prepared target that the initiating client did not +receive. Inventory and exact-ID access make it recoverable; automatic handoff recovery remains +separate work. + +Mecatui receives head-only inventory automatically from a capable server. It does not add a +historical-generations view in this increment. Administrative clients can request all physical +rows through gRPC or HTTP. + +## See also + +- [Acceptance plan](../acceptance/session-configuration-generations.md) +- [ADR 0065](./0065-conversation-fork.md) - independent conversation forks +- [ADR 0068](./0068-effort-change-via-fork.md) - reasoning-effort replacement mechanics +- [ADR 0071](./0071-seamless-model-switch.md) - model-switch carryover +- [ADR 0217](./0217-session-discovery-continuation.md) - session taxonomy and inventory +- [ADR 0291](./0291-server-owned-session-placement.md) - current ForkSession and successor surface +- [ADR 0258](./0258-cryptographic-session-incarnations.md) - exact session lifetimes +- [ADR 0248](./0248-sdk-compatibility-and-error-contract.md) - feature negotiation diff --git a/docs/adr/README.md b/docs/adr/README.md index 8c68ec7944..9350827375 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,7 +40,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE - [0007 — Twelve-patterns audit](./0007-twelve-patterns-audit.md) *(historical)* - [0027 — Cloud-native arc](./0027-cloud-native.md) - [0104 — Session families get a bounded, injective, non-reversible physical name](./0104-session-family-physical-naming.md) -- [0217 — Session discovery uses durable kind metadata and an authoritative transcript](./0217-session-discovery-continuation.md) +- [0217 — Session discovery uses durable kind metadata and an authoritative transcript](./0217-session-discovery-continuation.md) *(decision 8 superseded by 0285; model/effort replacement lineage clause superseded by proposed 0343)* +- [0343 - Group explicit configuration replacements as session generations](./0343-session-configuration-generations.md) *(proposed; one-time exact predecessor activation preserves predecessor-only identity and bounded fail-visible inventory)* - [0285 — Predictable actionable mecatui session handles](./0285-predictable-mecatui-session-handles.md) *(ordinary fixed escaped raw-ID-prefix handles; supersedes ADR 0217 decision 8 without changing debugger evidence/incarnation handles or their digests)* - [0226 — Session storage separates current state, indexed metadata, and maintenance](./0226-session-storage-maintenance.md) - [0239 — Semantic stream retry and failed-step retry transport](./0239-semantic-stream-retry.md)