Documentation
¶
Overview ¶
Package sessions owns Harbor's session-lifecycle subsystem.
A Session is a longer-lived multi-turn conversation that contains many Runs. Identity for runtime concerns is the triple `(tenant, user, session)`; runs are scoped within sessions (RFC §6.9). The Session record itself is keyed in the StateStore at `Kind = "session.lifecycle"` with `RunID = ""` — sessions are session-scoped, not run-scoped.
Harbor ships a single concrete *Registry implementation that sits over the StateStore, codifying the typed-wrapper-over-generic StateStore contract. There is no driver pluralism at the session layer; driver pluralism lives at StateStore (in-mem / SQLite / Postgres already). Per AGENTS.md §4.4, optional-capability ceremony is forbidden when all V1 drivers (here: implementations) will implement everything.
Four lifetime invariants are load-bearing and pinned by tests:
- Identity captured immutably on Open — Touch / Close re-save the same identity from the existing record; mismatched ctx identity is rejected with ErrIdentityMismatch.
- Reopen-after-close forbidden — clients open a new SessionID.
- Cross-tenant SessionID reuse rejected — `SessionID=S` opened under Tenant A then attempted under Tenant B returns ErrSessionIDReuse, even though the StateStore key (which contains the full Quadruple) would not naturally collide.
- GC never reaps a session with a RUNNING task — enforced when the TaskRegistry-backed probe (TaskRunningProbe) is wired via WithGCPolicy, as both reference assemblies do. The no-op default (returns false, nil) exists only for registries constructed without task awareness.
Lifecycle events (`session.opened / .touched / .closed / .gc_reaped`) land on the EventBus as `SafePayload` types — they're Harbor-internal markers with no secret-shaped fields by construction (RFC §6.13). Subscribers can extract typed fields directly, no redactor walk in between.
Index ¶
- Constants
- Variables
- type AutoNamingState
- type CascadeEraser
- type CascadeEraserDeps
- type Clock
- type GCPolicy
- type Option
- type Registry
- func (r *Registry) AutoNamingState(ctx context.Context, id string, ident identity.Identity) (AutoNamingState, error)
- func (r *Registry) Close(ctx context.Context, id string, reason string) error
- func (r *Registry) CloseRegistry(_ context.Context) error
- func (r *Registry) EnsureOpen(ctx context.Context, ident identity.Identity) (*Session, error)
- func (r *Registry) Erase(ctx context.Context, id string) error
- func (r *Registry) GC(ctx context.Context, policy GCPolicy) (int, error)
- func (r *Registry) Get(ctx context.Context, id string) (*Session, error)
- func (r *Registry) Inspect(ctx context.Context, id string) (*SessionSnapshot, error)
- func (r *Registry) ListSnapshots(ctx context.Context, f SessionListFilter) ([]SessionSnapshot, error)
- func (r *Registry) Open(ctx context.Context, id string, ident identity.Identity) (*Session, error)
- func (r *Registry) RecordCompletedTurn(ctx context.Context, id string, ident identity.Identity) (int, error)
- func (r *Registry) SetTitle(ctx context.Context, id string, ident identity.Identity, title string) error
- func (r *Registry) SetTitleAuto(ctx context.Context, id string, ident identity.Identity, title string) error
- func (r *Registry) Touch(ctx context.Context, id string) error
- type RunningProbe
- type Session
- type SessionClosedPayload
- type SessionErasedPayload
- type SessionGCReapedPayload
- type SessionLimits
- type SessionListFilter
- type SessionLister
- type SessionOpenedPayload
- type SessionRegistry
- type SessionSnapshot
- type SessionTitleChangedPayload
- type SessionTouchedPayload
- type TitleSource
Constants ¶
const ( EventTypeSessionOpened events.EventType = "session.opened" EventTypeSessionTouched events.EventType = "session.touched" EventTypeSessionClosed events.EventType = "session.closed" EventTypeSessionGCReaped events.EventType = "session.gc_reaped" // EventTypeSessionErased marks a completed data-lifecycle erasure // (`sessions.delete`). It is the ONLY durable trace of the erasure — // a redacted, content-free record-of-fact emitted under the actor's // observability scope, NEVER under the erased session's own identity // (re-persisting under the erased triple would leave the session's // `state.history` non-empty — RFC §6.13 / §7). EventTypeSessionErased events.EventType = "session.erased" // EventTypeSessionTitleChanged marks a successful title change. It has TWO // producers: the `sessions.set_title` verb via SetTitle (a manual set OR // clear) and the internal auto-namer via SetTitleAuto (an auto set). // Content-free by construction: the title string NEVER rides this payload // — only the SessionID and the resulting TitleSource ("manual" / "auto" / // "" on clear). Consumers refetch the projection (`sessions.list` / // `sessions.inspect`) for the title text. EventTypeSessionTitleChanged events.EventType = "session.title_changed" )
Session lifecycle event types. Each is registered with the events package's exhaustive registry via init() so Publish accepts them without ErrUnknownEventType. Subscribers can filter on these via events.Filter.Types.
const MaxSessionTitleLen = 200
MaxSessionTitleLen bounds a manual session title (runes, post-trim). A title exceeding this bound is rejected fail-loud with ErrInvalidTitle — never silently clamped (CLAUDE.md §13).
Variables ¶
var ( // ErrReopenAfterClose — Open called for a SessionID whose existing // record is Closed. Per RFC §6.9 ("Reopen-after-close is forbidden"). ErrReopenAfterClose = errors.New("sessions: reopen-after-close forbidden") // ErrSessionIDReuse — Open called with a SessionID already opened // under a different (tenant, user). Per RFC §6.9 ("reusing a session // ID across tenants/users is rejected"). ErrSessionIDReuse = errors.New("sessions: SessionID reused across tenants/users") // ErrIdentityMismatch — Touch / Close called with a ctx Identity // that disagrees with the stored session's Identity. The triple is // captured immutably on Open; mid-flight identity swaps are bugs. ErrIdentityMismatch = errors.New("sessions: ctx identity mismatches stored session identity") // ErrSessionNotFound — Get / Touch / Close / Inspect targeting a // SessionID that has no record (or the record was Deleted). ErrSessionNotFound = errors.New("sessions: session not found") // ErrSessionAlreadyOpen — Open called twice with the same triple // AND SessionID without an intervening Close. Distinct from // ErrReopenAfterClose (which fires when Closed is true). ErrSessionAlreadyOpen = errors.New("sessions: session already open") // ErrRegistryClosed — any operation called after CloseRegistry. ErrRegistryClosed = errors.New("sessions: registry is closed") // ErrSessionRunning — Erase was refused because the target session // has a RUNNING task. Erasure mirrors the GC never-reap-running // invariant (RFC §6.9): a session with in-flight work is durable // execution state, not a cache entry, so it is refused fail-loud and // NO store is touched. The caller retries after the task finishes. ErrSessionRunning = errors.New("sessions: cannot erase a session with a running task") )
Sentinel errors. Callers compare via errors.Is.
var ErrEraserMisconfigured = errors.New("sessions: CascadeEraser missing a mandatory dependency")
ErrEraserMisconfigured — NewCascadeEraser was called with a missing mandatory dependency. Fails closed (CLAUDE.md §5) rather than building an eraser that would nil-panic on the first erasure.
var ErrErasureRecordFailed = errors.New("sessions: erasure record-of-fact could not be durably completed")
ErrErasureRecordFailed — the cascade's destructive steps completed (in this attempt or an earlier converging one) but the durable record-of-fact could not be completed: a redactor refusal or a bus-publish failure at the final `session.erased` emit. Erase fails the WHOLE call loud rather than reporting success with a missing/incomplete audit trail. The session's scoped data IS gone; the durable erasure-ledger checkpoint (persisted before the irreversible StateStore.DeleteScope clear — see erasureLedgerRecord) survives, so a re-invoke converges: it skips straight to re-attempting the record + emit from the checkpointed cumulative counts, never re-running a destructive step.
var ErrInvalidTitle = errors.New("sessions: invalid title")
ErrInvalidTitle — SetTitle was called with a title that, after trimming leading/trailing whitespace, either exceeds MaxSessionTitleLen runes or contains a newline/control character. Titles are single-line, bounded, display strings.
var ErrManualTitle = errors.New("sessions: auto-naming refused — title is manual")
ErrManualTitle — SetTitleAuto was refused because the session's current TitleSource is TitleSourceManual. The internal auto-naming write path NEVER overwrites a human-set title: manual wins, structurally. A caller that sees this treats it as a benign skip (the run's outcome is unaffected), not a failure.
Functions ¶
This section is empty.
Types ¶
type AutoNamingState ¶ added in v1.12.0
type AutoNamingState struct {
// TitleSource is the session's current title provenance — the auto-namer
// never overwrites TitleSourceManual.
TitleSource TitleSource
// CurrentTitle is the session's current title (may be empty). The
// re-naming path includes it in the digest so the model can refine an
// existing auto title; it NEVER rides an event payload.
CurrentTitle string
// TurnCount / AutoNameCount / LastAutoNamedTurn are the session's naming
// counters (see Session).
TurnCount int
AutoNameCount int
LastAutoNamedTurn int
}
AutoNamingState is the read-side projection the auto-naming eligibility check consumes: the session's current title provenance plus its three naming counters. It is a lightweight read (no lifecycle mutation) so the terminal-boundary trigger can decide whether a title is due without a second whole-record write.
type CascadeEraser ¶ added in v1.7.0
type CascadeEraser struct {
// contains filtered or unexported fields
}
CascadeEraser performs the ordered, fail-loud, idempotent session erasure cascade behind `sessions.delete`: it refuses fail-loud on a running task, then deletes the session's scoped Artifacts, Memory, and State (the kind-agnostic StateStore scope delete removes the durable session-lifecycle record + run-scoped trajectories + planner checkpoints + the durable event stream), clears the registry's in-memory catalogs, and completes with a redacted, content-free `session.erased` audit event under the actor's observability scope.
There is no ACID transaction across the three independent stores; the cascade is ordered and every per-store delete is idempotent, so a mid-cascade error surfaces loudly (never a partial silent success — CLAUDE.md §13) and the cascade is safe to re-invoke to convergence. The refuse-if-running pre-flight runs FIRST so a refusal touches nothing; the event-bus fence (see fenceSession) runs immediately after it and BEFORE any destructive step, and a present-but-failing fence capability fails the whole cascade loud right there — the same retry-safe posture as a mid-cascade store error, just earlier.
The audit record is part of the erasure's success criteria ¶
Two follow-ups (issues #409/#410) hardened this cascade's audit trail on top of the ordering + fencing shape above. Previously, the final `session.erased` emit was best-effort AFTER the destructive steps completed: a transient bus/redactor failure lost the ONLY audit record while Erase still reported success, and — because the destructive steps (specifically StateStore.DeleteScope) had already removed the session-lifecycle record — a re-invoke returned ErrSessionNotFound, leaving no second chance to emit. Separately, a mid-cascade retry re-ran idempotent deletes that found fewer records the second time, so the reported counts reflected only the final converging attempt.
The BINDING ordering invariant: at no point may (data irrevocably gone) ∧ (no durable audit record) ∧ (success returned) hold — nor the inverse (record says erased, data present, success returned). The chosen mechanism is to persist a durable compliance checkpoint (erasureLedgerRecord, via the StateStore the cascade already drives) BEFORE each destructive step's result would otherwise become unrecoverable, rather than bounded-retrying the emit in-call. Concretely:
- The ledger checkpoint is persisted under the actor's observability scope (ledgerScope — same reserved (tenant, user, <erasure-audit>) slot the final event publishes under), NOT the erased triple, so it survives StateStore.DeleteScope's clear of the erased session's own scope. It is tenant/user-scoped OPERATOR AUDIT DATA about a deleted session — never user content — addressing the phase plan's identity-scoping risk note head-on.
- The ledger is checkpointed immediately after EVERY destructive step (artifacts, memory, state), accumulating each step's own count onto whatever a PRIOR interrupted attempt already contributed (loaded at the top of Erase). This is what makes counts cumulative across converging attempts (#410): a step whose data is already gone on a retry contributes 0 locally, but the ledger already holds the earlier attempt's non-zero contribution. The ledger carries the session lifecycle's OpenedAt stamp so accumulation NEVER crosses a session-id reuse boundary: when the target session still exists (a fresh attempt, not a converging retry) and a leftover ledger's stamp mismatches the live session's OpenedAt, the leftover belongs to an ABANDONED prior lifecycle of the reused id — it is discarded (with a loud Warn) and the cascade starts from zero, so a reused session id's counts reflect only its own lifecycle. Discarding necessarily forfeits the abandoned lifecycle's never-emitted record-of-fact: the caller was told loudly (ErrErasureRecordFailed) to re-invoke and instead reopened the id — inflating the NEW lifecycle's compliance counts to preserve a stale checkpoint would be the worse corruption.
- Because the ledger holds the complete, accurate counts BEFORE StateStore.DeleteScope ever runs (steps 1-2 checkpoint before step 3), the compliance record is durably persisted strictly before the irreversible clear — satisfying the first half of the invariant. The final record-of-fact emit (completeErasure/emitErased) reads ONLY from the ledger, never recomputing counts, so it can't drift.
- A re-invoke after the destructive steps completed but the final emit failed finds the session already gone (ErrSessionNotFound from the registry pre-flight) AND a still-present ledger checkpoint — recognized as a CONVERGING retry: it skips every destructive step (nothing left to do) and goes straight to completeErasure, which re-attempts the record + emit using the ledger's cumulative counts. Only a successful emit clears the ledger, so an emit failure keeps the session "re-invokable" without ever touching a store again.
- A redactor refusal or a bus-publish failure at the final emit both fail Erase loud with the wrapped sentinel ErrErasureRecordFailed (no `Error`-log-and-continue) — the same loud path, because both mean the durable record-of-fact did not land.
- The final emit is IDEMPOTENT per (session, lifecycle): before publishing, completeErasure checks the observability scope's retained history (when the bus supports events.HistoryReplayer) for a `session.erased` record already carrying this session id + lifecycle stamp, and on a hit skips the publish — converging silently to success and still clearing the ledger. This closes the duplicate-emit window when the publish succeeded but the ledger cleanup failed and the caller re-invoked. The check is capability-gated and bounded (see recordAlreadyEmitted): when it cannot verify, it emits — a duplicate compliance record is the acceptable failure direction, a lost one never is.
- Concurrent Erase calls for the SAME session are serialized by a striped IN-PROCESS lock (lockSession) so exactly one goroutine in this process ever runs the cascade for a given session at a time: the loser blocks until the winner's cascade (through ledger cleanup) is fully done, then sees the genuine ErrSessionNotFound with no ledger — "one wins, one gets the not-found path, never a double event." HONESTY NOTE — the lock is per-process only: two runtime REPLICAS racing sessions.delete for the same session are NOT serialized (their ledger checkpoints are last-writer-wins StateStore saves, and both may run destructive steps — each of which is idempotent, so the data outcome converges). The cross-replica residuals are count skew on the racing attempts and a possible duplicate emit where the item-6 history check races the other replica's publish; never a lost record, never resurrected data. Cross-replica serialization would need a StateStore CAS/lease primitive — the same recorded limitation as the agent-config registry's per-process owner locks.
Documented residual gap (accepted, not silently dropped): there is no ACID transaction spanning a destructive store (artifacts/memory/state) and the StateStore the ledger checkpoint itself is persisted through — a generalized transactional-outbox subsystem is an explicit non-goal of this design. A failure in the narrow window between a destructive step succeeding and its OWN checkpoint save committing means that step's contribution is not durably recorded; a converging retry's local count for that step is by then 0 (already deleted), so the final total under-counts by exactly that step's amount. This is strictly narrower than the earlier whole-cascade gap it replaces (a failure ANYWHERE previously lost the whole attempt's counts) and is pinned by TestCascadeEraser_LedgerSaveFailure_LoudAndRetrySafe rather than left silently untested. It never affects the fail-loud / no-lost-record invariant (CLAUDE.md §13) — only count exactness on this specific interleaving.
A constructed *CascadeEraser is immutable after construction and safe to share across N concurrent goroutines: every method's per-call state lives in the call's arguments and locals, the shared stores + registry are each independently concurrency-safe, and the fixed-size striped lock array (eraseLocks) is the one internally-synchronized mutable field — it never grows with the number of distinct sessions ever erased.
func NewCascadeEraser ¶ added in v1.7.0
func NewCascadeEraser(deps CascadeEraserDeps) (*CascadeEraser, error)
NewCascadeEraser builds the session-erasure cascade orchestrator. Registry / State / Memory / Artifacts / Bus are mandatory — a nil fails loud with a wrapped ErrEraserMisconfigured. The returned *CascadeEraser is immutable after construction and safe for concurrent use by N goroutines.
func (*CascadeEraser) Erase ¶ added in v1.7.0
func (e *CascadeEraser) Erase(ctx context.Context, id identity.Identity) (prototypes.SessionsDeleteResponse, error)
Erase runs the full erasure cascade for the verified identity. The identity is the caller's own verified `(tenant, user, session)` — the own-session-only scope contract is enforced at the wire / service edge before Erase is reached. Order (RFC §6.9, hardened with the durable record-of-fact ordering + cumulative counts described above):
- lock: serialize concurrent Erase calls for this exact session (lockSession) so only one goroutine ever runs the steps below for it at a time; load any durable erasure-ledger checkpoint a PRIOR interrupted attempt left behind.
- refuse-if-running: load+verify the session record under id and probe the running-task seam. A refusal touches NO store — UNLESS a ledger checkpoint already exists, in which case ErrSessionNotFound here means the destructive steps already fully completed on a prior attempt and only the final record-of-fact remains: Erase converges via completeErasure without touching artifacts / memory / state again. When the session EXISTS, the ledger's lifecycle stamp (the session's OpenedAt) is verified: a leftover ledger from an abandoned prior lifecycle of a reused session id is discarded with a loud Warn so counts never inflate across a reuse boundary.
- fence: mark the triple erased on the event bus, BEFORE any destructive step. A present-but-failing Fence capability fails the WHOLE erasure loud here — nothing has been touched yet, so this is retry-safe. A bus that does not implement the capability at all is a logged downgrade, not an error (see fenceSession).
- artifacts: enumerate the session's artifacts and delete each, then durably checkpoint the cumulative count (issue #410).
- memory: flush the session's memory to a clean state, then checkpoint.
- state: kind-agnostic scope delete (the session-lifecycle record, run-scoped trajectories, planner checkpoints, and the durable event stream all live under the triple and all go) — THE irreversible clear — then checkpoint the final cumulative count. Because steps 3-5 each checkpoint before the NEXT step runs, the ledger is always durably complete strictly before this step's clear takes effect ("record before the irreversible clear" ordering).
- complete: clear the registry's in-memory catalogs, build the redacted content-free `session.erased` payload from the ledger's cumulative counts, publish it (skipping idempotently when a record for this session+lifecycle already exists — recordAlreadyEmitted), and — only once the record durably exists — remove the ledger checkpoint (completeErasure).
Returns ErrSessionRunning (refused — 409), ErrSessionNotFound (absent under the caller's identity AND no pending ledger — 404), ErrErasureRecordFailed (destructive steps done, record incomplete — re-invokable), or a wrapped store/fence error (loud, retry-safe: every step through state.DeleteScope is idempotent, and the fence step (2) runs before step 3 ever touches a store). The response carries non-sensitive deletion telemetry only.
type CascadeEraserDeps ¶ added in v1.7.0
type CascadeEraserDeps struct {
Registry *Registry
State state.StateStore
Memory memory.MemoryStore
Artifacts artifacts.ArtifactStore
Bus events.EventBus
Redactor audit.Redactor
Clock Clock
Logger *slog.Logger
}
CascadeEraserDeps bundles the seams the CascadeEraser drives. Registry, State, Memory, Artifacts, and Bus are mandatory; Redactor, Clock, and Logger are optional.
type Clock ¶
Clock abstracts time so GC tests are deterministic without time.Sleep. Production code uses realClock; tests pass a fakeClock.
The interface intentionally returns time.Time directly (not a monotonic count) so GC's wall-clock math is identical between production and test paths.
type GCPolicy ¶
type GCPolicy struct {
IdleTTL time.Duration
HardCap time.Duration
SweepInterval time.Duration
RunningProbe RunningProbe
}
GCPolicy bundles the GC sweeper's tunables. Defaults match RFC §6.9: IdleTTL 24h, HardCap 720h (30 days), SweepInterval 15m. The RunningProbe should be TaskRunningProbe wherever a TaskRegistry is in scope; default is the no-op.
type Option ¶
type Option func(*Registry)
Option configures a Registry at construction. Only WithClock is exported; production code does not touch it. Tests use a fake clock to drive GC deterministically.
func WithClock ¶
WithClock injects a custom Clock. Production code uses realClock; the test suite uses a controllable clock to exercise hard-cap GC without time.Sleep (per AGENTS.md §11).
func WithGCPolicy ¶
WithGCPolicy injects an explicit GCPolicy at construction. When omitted, the GCPolicy is built from the SessionsConfig defaults + the no-op RunningProbe. Production wiring (cmd/harbor and harbortest/devstack) passes a policy whose RunningProbe is TaskRunningProbe so GC honors RFC §6.9.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry is the StateStore-backed implementation of SessionRegistry. It is the single concrete impl in V1 — driver pluralism lives at the StateStore layer.
Concurrency model:
- StateStore writes go through `state.StateStore.Save` which is itself concurrent-safe per the concurrent-reuse contract.
- The cross-tenant SessionID-uniqueness map is guarded by mu.
- The sweeper goroutine's lifecycle is owned by `done` + `wg`.
func New ¶
func New(store state.StateStore, cfg config.SessionsConfig, bus events.EventBus, opts ...Option) (*Registry, error)
New constructs a Registry. The store and bus are required; cfg supplies the GC tunables (defaults applied if zero); opts can override the Clock or GCPolicy.
The sweeper goroutine starts immediately and runs at gcPolicy.SweepInterval until CloseRegistry is called. The probe defaults to no-op; assemblies that own a TaskRegistry MUST pass TaskRunningProbe via WithGCPolicy (both reference assemblies do) so GC never reaps a session with a RUNNING task.
func (*Registry) AutoNamingState ¶ added in v1.12.0
func (r *Registry) AutoNamingState(ctx context.Context, id string, ident identity.Identity) (AutoNamingState, error)
AutoNamingState reads the session's title provenance + naming counters for the terminal-boundary eligibility check. It is a read-only load (no lifecycle mutation, no lock held across the read) under the run's verified triple; an unknown / erased id returns ErrSessionNotFound, a stored-identity mismatch returns ErrIdentityMismatch.
func (*Registry) Close ¶
Close marks the session Closed and emits session.closed. Idempotent: closing an already-closed session is a no-op AND preserves the original ClosedReason. The load→mutate→save runs through mutateSession (one r.mu critical section — this previously loaded OUTSIDE the lock, leaving a lost-update window where a concurrent SetTitle / Touch could re-persist Closed=false after Close returned).
func (*Registry) CloseRegistry ¶
CloseRegistry cancels the sweeper goroutine and joins it. Idempotent. Subsequent operations return ErrRegistryClosed.
func (*Registry) EnsureOpen ¶ added in v1.2.0
EnsureOpen is the create-on-first-use entry point. It returns the live session for `ident`, creating it if no record exists. The session id is `ident.SessionID` (the per-request session the client chose); the (tenant, user) come from the verified connection token.
Semantics:
- No record yet → Open it (create) and return the fresh session.
- Open record at this exact triple → no-op, return the existing session (a second turn in the same conversation is not an error).
- Closed record at this triple → ErrReopenAfterClose (RFC §6.9: a GC-reaped or operator-closed session is read-only; a NEW conversation must pick a NEW session id). The caller maps this to a clear client error; it is never a silent revive.
EnsureOpen is identity-mandatory: an incomplete triple fails closed. It is the seam the Protocol ControlSurface calls on `start` so the first turn of a brand-new conversation materialises the session row the Console's sessions.list reads.
func (*Registry) Erase ¶ added in v1.7.0
Erase is the registry-side pre-flight + in-memory clear for a session erasure (`sessions.delete`). It runs the fail-loud preconditions that MUST hold before any store is touched — load+verify the record under the caller's verified ctx identity, then probe the RunningProbe seam — and, when they pass, clears the in-memory open-sessions / id-index map entry and the per-(tenant, user) discovery catalog.
Erase performs NO durable record delete of its own: the durable `session.lifecycle` record is itself a StateStore record under the triple, removed by the kind-agnostic StateStore.DeleteScope step of the cascade. Calling Erase standalone leaves the durable record in place (the caller is expected to run DeleteScope); the erasure orchestrator composes the two via the unexported preflightErase / clearErased halves so the probe runs FIRST (a refusal touches nothing) and the in-memory clear runs LAST (after DeleteScope).
Identity-mandatory: the ctx identity scopes the read, and its SessionID must equal id. Returns ErrSessionNotFound when no record is visible to the caller's identity, ErrSessionRunning when the probe reports a RUNNING task (no store touched), ErrIdentityMismatch when the stored identity disagrees with the caller's.
func (*Registry) GC ¶
GC performs a single sweep pass. For each currently-open session:
- if RunningProbe returns true: skip (per RFC §6.9 "GC never reaps a session with a RUNNING task").
- else if LastSeen + IdleTTL < clock.Now(): close with reason "gc:idle".
- else if OpenedAt + HardCap < clock.Now(): close with reason "gc:hard_cap" (the hard cap wins over recent Touch).
Returns the number of sessions reaped and the first probe / close error encountered (the sweep continues past errors so a single bad session doesn't block the rest).
func (*Registry) Get ¶
Get loads the session with `id` for the identity in ctx. Returns ErrSessionNotFound when the record is absent. Identity-mandatory: the ctx Identity is used to scope the StateStore read; cross-tenant access is prevented because the StateStore key contains the full triple.
func (*Registry) Inspect ¶
Inspect returns a SessionSnapshot. Running is derived from the configured RunningProbe at inspection time.
func (*Registry) ListSnapshots ¶
func (r *Registry) ListSnapshots(ctx context.Context, f SessionListFilter) ([]SessionSnapshot, error)
ListSnapshots implements sessions.SessionLister — the `search.sessions` read-side projection. Returns snapshots for every session the registry has seen (open OR closed) matching the filter.
The registry's in-memory `idIndex` is the catalog of every SessionID that has been Opened during this registry's lifetime. The snapshot is built by Loading each matching session from the StateStore so the Closed / ClosedAt / LastSeen fields are current; Running is derived from the GCPolicy RunningProbe at inspection time (mirroring Inspect's contract).
The caller (the search subsystem) is responsible for the auth scope gate — ListSnapshots does NOT re-check scope. It DOES validate that every supplied `TenantIDs` / `UserIDs` / `SessionIDs` entry is non-empty (a no-op for empty filters).
Concurrent reuse: ListSnapshots only reads `idIndex` / `openSessions` under the registry's mutex; no per-call state lives on `*Registry`. One Registry serves N concurrent ListSnapshots safely.
func (*Registry) Open ¶
Open creates a new session record, captures the identity triple immutably, and emits session.opened. Cross-tenant SessionID reuse and reopen-after-close are rejected; same-triple double-open is rejected with ErrSessionAlreadyOpen.
func (*Registry) RecordCompletedTurn ¶ added in v1.12.0
func (r *Registry) RecordCompletedTurn(ctx context.Context, id string, ident identity.Identity) (int, error)
RecordCompletedTurn increments the session's TurnCount and returns the new value. The run loop's terminal boundary calls it once per completed run WHEN a naming policy is active — never otherwise, so a naming-off runtime writes nothing (the opt-in invariant). The load→bump→save runs through mutateSession (one r.mu critical section, shared with Touch / Close / SetTitle / SetTitleAuto / the GC reap and the eraser's deleteScopeSerialized) so a concurrent write can never lose the increment or resurrect an erased record.
`ident` is the run's verified (tenant, user, session). The target session is loaded under that exact triple; a session belonging to a different (tenant, user, session) is not found at that StateStore key (ErrSessionNotFound). A stored-identity mismatch (defence-in-depth) returns ErrIdentityMismatch. Renaming/counting a CLOSED session is allowed (a run can complete against a session the operator closed mid-flight); an ERASED session is ErrSessionNotFound.
func (*Registry) SetTitle ¶ added in v1.12.0
func (r *Registry) SetTitle(ctx context.Context, id string, ident identity.Identity, title string) error
SetTitle sets or clears the manual title of session `id` for the CALLER's verified `(tenant, user)` (`ident`). Unlike Touch/Close/ Inspect, the target `id` is NOT required to equal `ident.SessionID` — the write scope is `(tenant, user)`, matching `sessions.list`, so a caller may rename a sibling session. `ident` still carries a full triple (identity.Validate requires all three components) — its SessionID is the caller's OWN connecting session and is used only for identity validation, never as the load key.
Semantics: the title is trimmed; an empty-after-trim value clears Title, resets TitleSource to TitleSourceUnset, AND zeroes the auto-naming counters (AutoNameCount / LastAutoNamedTurn) in the same save — a clear starts a NEW auto-naming arming cycle, so a re-arm fires again (the cap is per-cycle, not per-session-lifetime). A non-empty value is validated (MaxSessionTitleLen, no newline/control characters — ErrInvalidTitle on violation, never a silent clamp per CLAUDE.md §13) and stored with TitleSource = TitleSourceManual. A write that changes nothing (clearing an already-unset title, or an identical manual re-set) is a no-op: nothing is persisted and no session.title_changed event is emitted. The target session is loaded under the caller's own (tenant, user) plus the target id — a session belonging to a DIFFERENT (tenant, user) is simply not found at that StateStore key, so a cross-user/cross-tenant rename attempt surfaces as the same ErrSessionNotFound a genuinely unknown id would (existence is never revealed across identities). Renaming a CLOSED session is allowed (title is metadata on a historical conversation, not a lifecycle mutation); renaming an ERASED session is ErrSessionNotFound (DeleteScope already removed the record).
func (*Registry) SetTitleAuto ¶ added in v1.12.0
func (r *Registry) SetTitleAuto(ctx context.Context, id string, ident identity.Identity, title string) error
SetTitleAuto sets the session's title from the internal auto-namer. It REFUSES with ErrManualTitle when the current TitleSource is TitleSourceManual — manual wins, structurally, so a human's title is never overwritten by the runtime. On success it stores the (defensively re-clamped) title with TitleSource = TitleSourceAuto and bumps AutoNameCount + LastAutoNamedTurn in the SAME record save (never a torn two-write update), then publishes a content-free session.title_changed (source=auto).
`title` is expected to be already clamped by the caller (the trigger) to the naming policy's max-title-len; the registry re-clamps defensively to MaxSessionTitleLen runes on a single line and rejects an empty-after-trim title with ErrInvalidTitle (the auto path never intentionally clears — an empty candidate is the caller's `empty_title` skip, handled before this call). The whole read→refuse/mutate→save runs through mutateSession (the one serialized whole-record path).
func (*Registry) Touch ¶
Touch updates LastSeen and re-saves. Identity-mandatory; ctx Identity is compared against the stored Identity, mismatch returns ErrIdentityMismatch. Touch on a Closed session returns ErrReopenAfterClose (Closed records are read-only). The load→mutate→save runs through mutateSession (one r.mu critical section) so a concurrent Close / SetTitle / GC reap can never lose this write — or have this write resurrect theirs.
type RunningProbe ¶
RunningProbe is the seam the GC sweeper consults so it can honor "never reap a session with a RUNNING task" (RFC §6.9). The TaskRegistry-backed implementation is TaskRunningProbe; both reference assemblies wire it via WithGCPolicy. A nil probe is treated as the no-op default (returns false, nil) — only for registries constructed without task awareness.
func TaskRunningProbe ¶ added in v1.3.0
func TaskRunningProbe(reg tasks.TaskRegistry) RunningProbe
TaskRunningProbe adapts a tasks.TaskRegistry into the RunningProbe seam so the GC sweeper can enforce RFC §6.9's "GC never reaps a session with a RUNNING task" invariant. The probe lists the session's tasks filtered to tasks.StatusRunning (the exact status the RFC names — PENDING / PAUSED tasks do not block GC) and reports whether any exist.
Wiring: both reference assemblies (cmd/harbor `bootDevStack` and harbortest/devstack `Assemble`) pass this probe via sessions.New(..., WithGCPolicy(GCPolicy{RunningProbe: ...})). A registry constructed without it falls back to the no-op default, which reports no running tasks — sessions with in-flight work become reapable, so assemblies that own a TaskRegistry MUST wire this adapter.
The import direction is sound by construction: internal/tasks does not import internal/sessions (verified at the time of authoring), so the adapter lives here, next to the RunningProbe type it satisfies.
type Session ¶
type Session struct {
ID string
Identity identity.Identity
OpenedAt time.Time
LastSeen time.Time
Closed bool
ClosedAt time.Time
ClosedReason string
Limits SessionLimits
Context map[string]any
Title string
TitleSource TitleSource
// TurnCount / AutoNameCount / LastAutoNamedTurn are the auto-naming
// counters. They are additive JSON fields: an older persisted record
// (written before auto-naming existed) decodes with all three
// zero-valued. They are written ONLY when a naming policy is active for
// the run (a config-free runtime never touches them), so a naming-off
// fleet carries no per-run write amplification and the counters read
// zero for every session. Written exclusively through
// RecordCompletedTurn (bumps TurnCount) and SetTitleAuto (bumps
// AutoNameCount + LastAutoNamedTurn alongside the title, in ONE record
// save).
//
// TurnCount counts runs completed while a naming policy was active —
// counting starts at policy enablement, so enabling naming mid-session
// counts turns from enablement, not from session open (a documented
// consequence of writing counters only when the policy is active).
// AutoNameCount is the number of auto-naming LLM calls that produced a
// title. LastAutoNamedTurn is the TurnCount at which the most recent
// auto-naming landed (the re-naming cadence anchor).
TurnCount int
AutoNameCount int
LastAutoNamedTurn int
}
Session is the persisted lifecycle record for one session. The Identity field carries the triple captured on Open and is immutable afterwards. Closed transitions to true on Close; ClosedAt stays zero while Closed is false.
Title / TitleSource are additive fields: an older persisted session record (one written before these fields existed) decodes with both zero-valued (""), which is exactly TitleSourceUnset — no migration needed. Title has two writers: SetTitle (the sessions.set_title verb — manual set/clear, with trim + length/control-character validation, and an empty value clears the title and resets the auto-naming counters) and SetTitleAuto (the internal auto-namer — writes TitleSource="auto", refusing to overwrite a manual title). Title is user-derived free text and MUST NEVER be copied into an event/log/audit payload (CLAUDE.md §7 rule 7 — content-free posture) — only SessionID + TitleSource ride SessionTitleChangedPayload.
Limits and Context are reserved slots — later phases will populate Limits with the cost / token ceilings and Context with the (version, hash, llm/tool ctx, memory, artifacts) quintuple sketched in RFC §6.9. round-trips both fields through marshal/unmarshal but applies no validation.
type SessionClosedPayload ¶
type SessionClosedPayload struct {
events.SafeSealed
SessionID string
ClosedAt int64 // unix nanoseconds
Reason string
}
SessionClosedPayload reports a Close. Carries the SessionID, the ClosedAt timestamp, and the operator-provided Reason. Reason is a short caller-controlled string — callers MUST NOT pass tool args, raw user input, or any secret-shaped material; the bus does not re-redact SafePayload types.
type SessionErasedPayload ¶ added in v1.7.0
type SessionErasedPayload struct {
events.SafeSealed
// SessionID is the session that was erased.
SessionID string
// StateRecordsDeleted is the number of StateStore records removed by
// the kind-agnostic scope delete. Cumulative across converging
// attempts.
StateRecordsDeleted int
// ArtifactsDeleted is the number of artifacts removed. Cumulative
// across converging attempts.
ArtifactsDeleted int
// MemoryPurged is true when the session's memory was flushed clean.
MemoryPurged bool
// ErasedAt is the erasure timestamp (unix nanoseconds).
ErasedAt int64
}
SessionErasedPayload reports a completed session erasure. It carries the erased SessionID plus the per-store deletion counts and the erasure timestamp — non-sensitive telemetry only, NO user content. The erased session id is a payload FIELD (not the event's Identity): the event is published under the actor's observability scope so it never re-persists durable state under the erased triple. SafePayload by construction — every field is a bounded id, count, or timestamp.
This is the durable record-of-fact for a right-to-erasure operation, and publishing it is part of `sessions.delete`'s SUCCESS CRITERIA rather than a best-effort afterthought: Erase only ever returns success once this payload has been redacted and durably published. The count fields are CUMULATIVE across any converging retries the cascade needed (see internal/sessions.CascadeEraser's package godoc) — the true total actually removed, never just the last attempt's own count.
type SessionGCReapedPayload ¶
type SessionGCReapedPayload struct {
events.SafeSealed
SessionID string
ReapedAt int64 // unix nanoseconds
Reason string
}
SessionGCReapedPayload reports a GC sweep reaping. Reason is one of "gc:idle" or "gc:hard_cap"; the same string is also stored in Session.ClosedReason. SafePayload by construction.
type SessionLimits ¶
type SessionLimits struct {
}
SessionLimits is reserved for later phases (cost ceilings, tool catalog). Empty at V1; round-trips through marshal.
type SessionListFilter ¶
type SessionListFilter struct {
TenantIDs []string
UserIDs []string
SessionIDs []string
SinceLastSeen time.Time
UntilLastSeen time.Time
IncludeClosed bool
}
SessionListFilter narrows ListSnapshots's result set. All fields are wildcards when empty. SinceLastSeen / UntilLastSeen filter by the LastSeen timestamp; zero means "no bound."
type SessionLister ¶
type SessionLister interface {
// ListSnapshots returns session snapshots that match the filter,
// scoped to the requested tenants. Empty `TenantIDs` matches every
// tenant the registry has seen (the search subsystem gates this
// on the caller's auth.ScopeAdmin claim — the registry does NOT
// re-check scope). Empty `UserIDs` / `SessionIDs` are wildcards.
ListSnapshots(ctx context.Context, f SessionListFilter) ([]SessionSnapshot, error)
}
SessionLister is the narrow read-side capability the `search.sessions` Searcher consumes. The triple `(tenant, user, session)` is the load-bearing isolation key (CLAUDE.md §6); the listing is server-enforced per the supplied SessionListFilter. The returned snapshots include both currently-open and previously-closed sessions — search wants the union.
Intentionally NOT on the SessionRegistry interface: the lister is a projection over the in-memory open-session index plus the StateStore. Concrete `*Registry` implements it; future drivers add it when their backing store gains a `list` capability (StateStore List is post-V1).
type SessionOpenedPayload ¶
type SessionOpenedPayload struct {
events.SafeSealed
SessionID string
OpenedAt int64 // unix nanoseconds; identity-of-record across drivers
}
SessionOpenedPayload reports a successful Open. Carries the SessionID and the OpenedAt timestamp; the identity triple lives on the Event itself, so it is intentionally NOT duplicated here.
SafePayload by construction — no secret-shaped fields.
type SessionRegistry ¶
type SessionRegistry interface {
Open(ctx context.Context, id string, ident identity.Identity) (*Session, error)
// EnsureOpen is the create-on-first-use entry point: it
// returns the live session for ident, creating it if absent and
// no-opping if already open. A closed session is NOT revived
// (ErrReopenAfterClose). See the concrete impl for full semantics.
EnsureOpen(ctx context.Context, ident identity.Identity) (*Session, error)
Get(ctx context.Context, id string) (*Session, error)
Touch(ctx context.Context, id string) error
Close(ctx context.Context, id string, reason string) error
Inspect(ctx context.Context, id string) (*SessionSnapshot, error)
GC(ctx context.Context, policy GCPolicy) (int, error)
// SetTitle sets or clears the manual title of session `id`. `ident`
// is the CALLER's verified (tenant, user, session) — id MAY name a
// sibling session of the same (tenant, user) (the write scope is
// (tenant, user), matching sessions.list); it is not
// required to equal ident.SessionID. Semantics: trims whitespace;
// an empty-after-trim title clears Title and resets TitleSource to
// TitleSourceUnset; a non-empty title is validated (MaxSessionTitleLen,
// no newline/control characters — ErrInvalidTitle, never a silent
// clamp) and stored with TitleSource = TitleSourceManual. Unknown id
// (or an id belonging to a different (tenant, user)) returns
// ErrSessionNotFound; a stored-identity (tenant, user) mismatch
// (defence-in-depth) returns ErrIdentityMismatch. Renaming a CLOSED
// session is allowed; renaming an ERASED session is
// ErrSessionNotFound (the record is gone).
SetTitle(ctx context.Context, id string, ident identity.Identity, title string) error
// RecordCompletedTurn increments the session's TurnCount and returns the
// new value. It is called from the run loop's terminal boundary ONLY when
// a naming policy is active for the run — a naming-off runtime never calls
// it, so it writes nothing for the naming-off fleet. `ident` is the run's
// verified (tenant, user, session); a stored-identity mismatch returns
// ErrIdentityMismatch, an unknown / erased id returns ErrSessionNotFound.
// The load→bump→save runs through the serialized whole-record write path
// (never a torn two-write update).
RecordCompletedTurn(ctx context.Context, id string, ident identity.Identity) (int, error)
// SetTitleAuto sets the session's title from the internal auto-namer and
// bumps AutoNameCount + LastAutoNamedTurn in the SAME record save. It
// REFUSES with ErrManualTitle when the current TitleSource is
// TitleSourceManual (manual wins, structurally). `title` is expected to be
// already clamped by the caller to the naming policy bound; the registry
// re-clamps defensively to MaxSessionTitleLen runes and single-line, and
// rejects an empty-after-trim title with ErrInvalidTitle (the auto path
// never intentionally clears). On success it stores TitleSource =
// TitleSourceAuto and publishes a content-free session.title_changed
// (source=auto). `ident` is the run's verified triple; a stored-identity
// mismatch returns ErrIdentityMismatch, an unknown / erased id returns
// ErrSessionNotFound.
SetTitleAuto(ctx context.Context, id string, ident identity.Identity, title string) error
// CloseRegistry cancels the sweeper goroutine and joins it.
// Idempotent. Distinct method name (rather than Close) so it
// doesn't collide with Close(id, reason).
CloseRegistry(ctx context.Context) error
}
SessionRegistry is the public surface every consumer ( envelope writer, Protocol surface, Console subscribers, etc.) talks to. One concrete impl ships in a later phase (`*Registry`); driver pluralism lives at the StateStore layer.
type SessionSnapshot ¶
SessionSnapshot is the read-side projection returned by Inspect. Carries the lifecycle fields plus a Running boolean derived from the GCPolicy.RunningProbe at inspection time. Running is intrinsically stale by the time the caller reads it; the same is true of any snapshot model.
type SessionTitleChangedPayload ¶ added in v1.12.0
type SessionTitleChangedPayload struct {
events.SafeSealed
// SessionID is the session whose title changed.
SessionID string
// Source is the resulting TitleSource after the call — a three-value
// domain: "manual" when a caller set a non-empty title via
// sessions.set_title, "auto" when the internal auto-namer set it via
// SetTitleAuto, and "" (TitleSourceUnset) when the title was cleared. The
// wire verb produces only "manual" / ""; the "auto" value comes from the
// runtime's own auto-naming path.
Source string
}
SessionTitleChangedPayload reports a successful title change from EITHER producer — the `sessions.set_title` verb (SetTitle: manual set or clear) or the internal auto-namer (SetTitleAuto: auto set). Carries the SessionID and the resulting Source ONLY — the title string itself NEVER rides this payload (the same content-free contract SessionErasedPayload follows): the title is user-derived free text, and SafePayload types are published WITHOUT a redactor pass (the bus skips the redactor for types sealed via SafeSealed), so any raw content here would leak straight to every subscriber. Consumers that want the title text refetch `sessions.list` / `sessions.inspect`.
SafePayload by construction — every field is a bounded id or a closed-set enum string.
type SessionTouchedPayload ¶
type SessionTouchedPayload struct {
events.SafeSealed
SessionID string
LastSeen int64 // unix nanoseconds
}
SessionTouchedPayload reports a Touch. Carries the SessionID and the new LastSeen timestamp. SafePayload by construction.
type TitleSource ¶ added in v1.12.0
type TitleSource string
TitleSource marks who produced a session's Title. The wire verb `sessions.set_title` ALWAYS writes TitleSourceManual (empty title clears both fields to TitleSourceUnset) — TitleSourceAuto is not expressible over the wire; it is declared here for the runtime's internal auto-naming writer, which is the only producer. This makes "manual wins" structurally unforgeable: a caller can never claim `auto` provenance for a title they set themselves.
const ( // TitleSourceUnset — no title has ever been set, or the title was // cleared (an empty SetTitle call resets both Title and // TitleSource to this zero value). TitleSourceUnset TitleSource = "" // TitleSourceAuto — the title was produced by the runtime's internal // auto-naming writer. Not reachable via the `sessions.set_title` // wire verb. TitleSourceAuto TitleSource = "auto" // TitleSourceManual — the title was set by an authenticated caller // via `sessions.set_title`. Manual titles are never silently // overwritten by an auto-namer. TitleSourceManual TitleSource = "manual" )