From 5ab73296bf624ac239ee73603627acd360a596ae Mon Sep 17 00:00:00 2001
From: lb <542828+lukebuehler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 10:48:32 +0200
Subject: [PATCH 1/8] local runtime doc
---
.../pNNN-local-runtime-without-temporal.md | 962 +++++++++++-------
1 file changed, 610 insertions(+), 352 deletions(-)
diff --git a/docs/roadmap/later/pNNN-local-runtime-without-temporal.md b/docs/roadmap/later/pNNN-local-runtime-without-temporal.md
index 1d0781c9b..105b837bb 100644
--- a/docs/roadmap/later/pNNN-local-runtime-without-temporal.md
+++ b/docs/roadmap/later/pNNN-local-runtime-without-temporal.md
@@ -1,363 +1,621 @@
# PNNN: Local runtime without Temporal
**Status**
-- Later / exploratory. Written 2026-10-01 as a review for roadmap discussion,
- not a decision.
-- Effort figures are estimates from reading the code, not from a prototype.
-- Direction preference: if we pursue a local runtime, it is Option C (one
- orchestration core, two substrates). A separate, forked local runtime
- (Option B) is not on the table.
-
-A local Lightspeed with no Temporal, Postgres or Docker looks achievable in
-three to four months with two engineers, with a validation spike running in
-parallel to the first phase. The
-reason is that Temporal only orchestrates our work: every durable fact already
-lives in Postgres and CAS, and the agent loop already runs in-process for
-evals.
-
-Direction:
-
-- Keep Temporal for the hosted runtime.
-- Lift session orchestration out of the Temporal workflow into a sans-IO
- `sessions` crate. This is worth doing on its own, before and without a local
- runtime.
-- Add a local runtime for interactive sessions on top of the same `sessions`
- crate: SQLite, a filesystem CAS and an embedded envd, behind the existing
- public API.
-- Leave bots, channels and schedules as hosted-only features.
-
-## How much we rely on Temporal
-
-Temporal is our orchestrator, not our database. The session event log,
-checkpoints, CAS and all domain records already live in Postgres, and the bot
-controller treats its Postgres row as authoritative. What only Temporal holds
-is in-flight orchestration: queued admissions, pending emissions and their
-retry backoffs, workflow-start dedupe, the bot inbox and coalescing buffers,
-chat delivery state, and Schedules.
-
-The orchestration surface is broad, though:
-
-- **7 workflow types:** session, sub-agent execution, environment job,
- transcription, bot controller, bot trigger fire, chat conversation.
-- **About 20k lines** in `temporal-workflow`, written directly against the
- Temporal SDK (`&mut WorkflowContext` everywhere), with no trait in between.
- About 40% is pure logic.
-- **About 36 client call sites** in the server: starts, signals, queries,
- describes and terminates.
-- **About 60 activities** across sessions, bots and channels, with about 32
- retry policies.
-- **External workers:** the TypeScript chat connectors are Temporal activity
- workers on their own task queues.
-
-The server crate is about 40k lines of production code. Most of it (gateway,
-environments, MCP, secrets, `SessionTools`) has little or no Temporal coupling.
-
-| Temporal capability | What we use it for | Local equivalent |
-| --- | --- | --- |
-| Durable workflow per session, replay, continue-as-new | `AgentSessionWorkflow` drives `CoreAgentDrive`, races admissions against running activities, runs preparation; rolls over at 10k history events | A tokio task per active session, rebuilt from the log and checkpoint on start (`create_or_load_session` already does this) |
-| Signals | About 13 API mutations funnel into one `submit_admissions` signal; `deliver_emission` carries workflow-to-workflow results | A channel into the session task; a persisted inbox only if an admission must survive a crash before it is committed |
-| Queries + poll loops | The gateway polls `status` every 500 ms in about 15 wait-until-accepted loops; bot, chat and job snapshots | A direct reply from the session task, which is simpler than today |
-| Activities with retry, timeouts, heartbeats | LLM, tools, storage, MCP, environment jobs; heartbeats are how cancellation reaches LLM and tool futures | Plain async calls with a retry helper and a cancellation token. Backoff state is lost on restart, which is acceptable locally. |
-| Durable timers | Await deadlines, promise hard deadlines, cancel watchdog, emission and start retries, bot idle-close | Timers recomputed from state on start; the engine's `await_wake` already derives the next wake from state |
-| Workflow-id dedupe, signal-with-start | Session start, environment jobs, workflow-tool executions, bot controllers, conversations | Unique keys in the store plus an in-process registry of live session tasks |
-| Workflow-tool protocol | Sub-agents, environment-job tools, external plugin workflows (contract is Temporal signals, queries and task queues) | In-process spawn for sub-agents and jobs. External plugins need another transport or stay hosted-only. |
-| Schedules | Bot cron and poll triggers | An in-process scheduler, or hosted-only |
-| Task queues to external workers | Telegram and WhatsApp connectors | Hosted-only |
-
-Several parts were already built without Temporal and would carry over
-unchanged:
-
-- The reapers, the environment reconciler and the CAS sweeper are plain tokio
- loops.
-- Clients follow progress by long-polling events from Postgres; there is no SSE
- or WebSocket.
-- The expected-head check on event appends and the promise reaper already
- assume that signals can be lost.
-
-## What already runs without Temporal
-
-Most of the agent already runs without Temporal: an in-process agent loop
-exists today and is used by `crates/eval` against real providers. The
-architecture work done so far (sans-IO engine, CAS references, store traits) is
-what makes a second runtime plausible.
-
-| Piece | State today | Reusable as-is for local? |
+
+- Later / exploratory. Written 2026-10-01; direction and code review updated
+ 2026-10-06. No implementation started.
+- Preferred architecture: one orchestration core, two execution substrates
+ (Option C below). A separately implemented local session runtime is not the
+ direction.
+- Restart policy agreed 2026-10-06: interrupt unfinished ordinary tool attempts,
+ preserve durable runs and sub-agent supervision, and continue where possible.
+- Local v1 excludes external/custom workflow tools, externally managed sessions
+ and SDK callback functions. A later SDK-host protocol is exploratory.
+- Packaging and Platform connectivity still need decisions.
+- Code-sharing and effort figures are estimates from source review, not a
+ prototype or delivery commitment.
+
+Lightspeed should be easy to deploy as one local universe as well as operate
+as a fully managed agent system. The local runtime is a small deployment of
+that system, supporting most core session behavior. Building a competing local
+coding-agent CLI is not the main objective.
+
+## Direction: one universe per runtime process
+
+The preferred local shape is one process owning one universe: the API gateway,
+session orchestration, effect execution and runtime maintenance run together,
+mostly as asynchronous Tokio tasks, with multiple threads where useful. Do
+not introduce independently deployed gateway and worker roles for local v1.
+The exact gateway/runtime packaging boundary remains open, but separate crates
+or internal interfaces do not require separate processes.
+
+That universe can contain many sessions, sub-agents, VFS workspaces and
+attached environments. It is not one process per conversation, and it is not
+necessarily the current checkout or working directory. Several clients may
+connect to the same owning process. Running another independent universe means
+another process with its own data directory.
+
+The intended scope is:
+
+- Keep Temporal and PostgreSQL for the hosted runtime.
+- Extract shared session orchestration into a sans-IO `sessions` crate above
+ `harness`; use it from both Temporal and a local Tokio interpreter.
+- Store runtime-owned universe data in **one SQLite database**, with CAS bytes
+ in the filesystem, together behind **`store-fs`**. This mirrors `store-pg`
+ owning both record and CAS storage; a separate `store-sqlite` crate is not the
+ preferred package boundary.
+- Support sessions and runs, queue/steer/cancel, approvals, context and
+ compaction, events, fork/clone, VFS, profiles and skills, models, MCP,
+ sub-agents, and resuming persisted sessions. Aim to preserve existing session
+ behavior under the local restart policy below.
+- Support different execution environments through the existing environment
+ protocol. The machine hosting the runtime is just another explicitly enabled
+ environment, accessed through envd. Bundled, embedded or sidecar envd
+ packaging is undecided.
+- Use environment variables for user/provider credentials by default. Runtime
+ configuration may store references to those variables, not their values.
+ OAuth and any necessary local credential persistence remain open.
+- Let the Platform attach/adopt a local universe as well as manage hosted
+ universes. The Platform is an optional, separate management application;
+ standalone local execution must not require its login or database.
+- Leave bots, channels and their schedules out of local v1. Session waits,
+ promise deadlines and cancellation timers remain in scope.
+- Leave external/custom workflow-tool integrations, `session/managed/start`
+ and SDK-hosted local functions out of local v1. Ordinary sessions remain
+ controllable through the public API/SDK. Built-in sub-agents and environment
+ jobs still use the generic protocol internally; MCP remains in scope.
+
+The single-process rule concerns ownership of the runtime and its store.
+Execution environments, shell commands, and potentially SDK tool hosts may
+have their own processes. Whether a packaged envd counts as an allowed sidecar
+or must run inside the runtime is an explicit packaging question, not a reason
+to split session ownership across workers.
+
+## Why this is feasible, and what single-process ownership does not solve
+
+The deterministic harness already runs without Temporal. One owning process
+also removes the need for distributed worker leases, cross-process session
+ownership, and failover coordination. SQLite transactions plus an exclusive
+lock on the universe data directory are a plausible local foundation. Async
+concurrency and multiple Tokio threads do not require multiple database owners.
+Each session still needs serialized state transitions and expected-head checks
+when committing its events, even when effects execute concurrently.
+
+Temporal still supplies behavior that a process and a database do not provide
+by themselves. The session event log, checkpoints and CAS are persisted outside
+Temporal, but queued admissions, preparation receipts, pending delivery and
+other orchestration state also live in Temporal. Replaying only the session log
+is not a complete recovery strategy.
+
+The local design has these persistence and ownership requirements. The restart
+behavior is decided below; journal layout and other storage mechanics still
+need design work.
+
+| Boundary | Local requirement |
+| --- | --- |
+| Ownership | One runtime holds the data-directory lock for its lifetime. A second opener attaches to it or fails clearly; it must not independently drive sessions against the same database. |
+| API acknowledgement | Define the durable acceptance point. Work acknowledged as accepted must be committed to the event log or a recoverable inbox before replying; otherwise the response must mean something weaker explicitly. |
+| Session restart | Load the harness log/checkpoint plus orchestration records or reproducible cursors. Preparation idempotency receipts, pending admissions and unresolved child executions cannot simply disappear. |
+| Emissions and completion | Preserve an outbox or a reconstructible delivery cursor, stable invocation IDs and completion deduplication. There is a crash window between committing an event and delivering its effect. |
+| External effects | Apply the agreed restart policy below: interrupt ordinary unfinished tool attempts, restart pending model/compaction operations, and preserve durable child runs. External effects may already have happened; single-process execution does not imply exactly-once effects. |
+| Cancellation | Cancellation is a request to the effect adapter. Suppress stale results by operation identity even when the underlying request or remote process cannot be stopped immediately. |
+| Sleep and deadlines | Persist absolute deadlines and recompute due work on wake/restart. No local work progresses while the process is stopped; remote environment jobs may continue and need reconciliation. Whether retry backoff itself survives restart is separate from preserving accepted work. |
+| CAS consistency | Make blob writes durable before committing references; collect unreferenced files later. SQLite and filesystem CAS are not one atomic transaction, so startup repair, collection and backup need an explicit protocol. |
+
+These requirements are much smaller than replacing Temporal's hosted
+availability and distributed scheduling guarantees. They still need tests that
+kill and restart the process at commit/effect boundaries. Reduced availability
+is reasonable locally; silently losing accepted work or rerunning an ambiguous
+shell command should not be an accidental consequence of that choice.
+
+## Agreed restart policy
+
+**Decision, 2026-10-06:** recover durable sessions, runs and their supervision
+relationships; terminate unfinished ordinary tool attempts with an interrupted
+result; continue the runs where possible. A process restart does not itself
+cancel a run or close a session.
+
+| Work at the time of the crash | Recovery behavior |
+| --- | --- |
+| Tool result already committed | Preserve it, including completed siblings in a partially finished batch. |
+| Ordinary tool call without a committed result | Record a terminal interrupted result. Do not automatically replay the old invocation. The agent receives the error and may inspect the situation before choosing a new action. |
+| Model generation or compaction without a committed result | Restart the pending logical operation. Interrupt its execution attempt without synthesizing a terminal generation failure, which currently fails the run. |
+| Approval wait or timer | Restore the wait and its existing absolute deadline. Waiting for approval is not an interrupted external execution. |
+| Sub-agent delegation | Recover the same child session/run and parent completion promise. Apply the ordinary-tool interruption policy inside the child and resume supervision. |
+
+Structured concurrency follows durable run/session ownership, not the lifetime
+of a Tokio future. Restore that ownership before scheduling recovered work,
+retain existing scopes and absolute deadlines, and respect recorded cancellation
+or session closure. Do not fail a delegation's promise while independently
+resuming its child. Reconcile partially prepared children and terminal children
+whose results were not delivered; preserve initialized configuration or pinned
+preparation inputs rather than rereading a changed profile and creating new work.
+
+The existing per-call completion path can represent an interrupted ordinary
+call as `Failed` with a `runtime_interrupted` error reason. It preserves completed
+sibling results and lets the active run continue once the batch finishes.
+For interrupted external executions represented by promises, resolve the
+affected promises and use the normal joined/await resume paths. An existing
+sub-agent delegation keeps its promise pending while its child recovers.
+
+“Aborted” describes the runtime abandoning an attempt; it does not prove that
+external execution stopped or that its side effects were rolled back. A
+hard-killed runtime cannot run cleanup. Separate envd processes deliberately
+survive connection loss, and an MCP server may continue processing a request.
+The local contract is therefore:
+
+- On graceful shutdown, cancel owned execution scopes, request targeted remote
+ cancellation and wait for bounded cleanup.
+- On restart, reconcile or cancel known owned remote executions before allowing
+ conflicting work to continue. Represent unconfirmed external outcomes
+ explicitly; an interrupted result must not imply that retrying is harmless.
+- Persist attempt identity, ownership and remote execution handles before
+ dispatch, so recovery can locate the work even if the start response was lost.
+ Distinguish attempts from logical invocations and discard stale completions
+ without failing the recovered session.
+- Complete recovery for a run and its supervision relationships before normal
+ dispatch resumes. Recovery itself must be safe to repeat after another crash,
+ producing only one terminal interruption result per abandoned call.
+- Cancel only work owned by the interrupted scope. A completed process tool
+ call may have returned a live handle or left a background service running;
+ interruption of a later read does not confer ownership of that process.
+ Never cancel all work on a shared envd as a runtime-recovery shortcut.
+- Automatic cancellation while the runtime remains dead would require explicit
+ envd/tool-host ownership leases or watchdogs. That is a later enhancement,
+ not a local v1 guarantee, and cannot universally cover arbitrary MCP servers.
+
+This is a bounded part of the local recovery implementation, not a redesign of
+structured concurrency. It still requires durable attempt records, child
+execution phases, completion deduplication and crash-boundary tests; it avoids
+having to transparently resume every external tool attempt.
+
+## Storage, environments and credentials
+
+### One local store package
+
+[`store-fs`](../../../crates/store-fs/src/lib.rs) already groups a filesystem
+CAS, JSONL session store and JSON VFS catalog. It is about 1.6k code lines,
+including tests. It is not a SQLite backend and does not implement the full
+hosted registry, access, blob-graph and orchestration storage surface.
+
+The proposed evolution is to put SQLite-backed records and the existing CAS
+layout behind that package. Use `store-pg` as the behavioral reference for the
+shared store traits, not as a schema to copy wholesale: local v1 does not need
+bot/channel tables, PostgreSQL-specific locking or every deployment feature.
+It does need session indexes and metadata, VFS heads/mounts, profiles,
+environment and MCP configuration, access/identity as required, CAS reachability,
+and the local orchestration records described above. Decide whether existing
+JSONL stores remain a test adapter or migrate; they should not become a second
+authoritative runtime database.
+
+One SQLite database means one logical database for runtime-owned universe
+records; journal/WAL files are implementation details. envd has its own
+filesystem domain and state today: daemon identity, job records and transfer
+journals. Its state must not be silently folded into the universe database.
+
+### Environments stay independent
+
+The existing environment client/protocol and runtime resolver provide the
+correct boundary for local and remote machines. There is no need to invent a
+special shell executor in the session harness. The host machine should be
+registered or configured as an environment and subject to the same attachment,
+readiness, capability and credential rules as another machine.
+
+VFS workspaces remain separate from the host checkout and envd filesystem.
+Creating a universe under `.lightspeed` must not implicitly mount, copy or
+synchronize the current directory into VFS. The default host environment root,
+whether it is enabled automatically, and its permissions remain decisions.
+
+The runtime can bundle envd for convenience, but packaging should preserve the
+protocol boundary. envd currently persists its own private daemon identity and
+job state under its configured state directory; it has no SQLite dependency.
+Embedding it does not automatically remove that separate lifecycle.
+
+### Environment-variable credentials first; OAuth unresolved
+
+There is already an
+[`EnvSecretResolver`](../../../crates/llm-runtime/src/secrets.rs), and runtime
+model resolution can fall back to environment-configured provider credentials.
+That is a good foundation for local configuration. Other paths need adaptation:
+[environment credential bindings](../../../crates/temporal-runtime/src/environments/credentials.rs)
+currently use auth grants, provider credentials or stored direct secrets.
+
+OAuth is not just an API key loaded once. Current auth flows persist PKCE
+verifiers, access/refresh tokens and rotated refresh tokens through the
+[`SecretStore`](../../../crates/auth/src/secrets.rs). Callback registration also
+assumes a gateway URL; some current MCP OAuth paths need a publicly fetchable
+HTTPS client metadata URL. Full OAuth parity therefore needs a policy for both
+mutable secrets and local callback reachability.
+
+Options to evaluate are an external credential broker, reauthentication with
+ephemeral local tokens, or opt-in persistent local credentials (for example an
+OS credential store or encrypted storage). No choice is made here. Generated
+runtime API credentials and envd's private identity need a separate explicit
+policy too. “Environment variables by default” must not be presented as a
+claim that the existing stack never persists any secret material.
+
+## Platform adoption: intended capability, not implemented multi-runtime support
+
+The Platform should offer two deployment choices through the same session UI:
+managed/hosted universes and attached local universes. Adopting a universe means
+connecting to its existing identity and data, not importing its sessions into
+the hosted runtime or taking over its local process.
+
+There is useful scaffolding today, but separate runtime endpoints do **not**
+currently work end to end:
+
+- [`universes.gatewayUrl`](../../../platform/db/src/schema/platform.ts) can name
+ an endpoint, but
+ [`clientOptions`](../../../platform/backend/src/runtime-client.ts) deliberately
+ rejects any URL other than `LIGHTSPEED_API_URL` and confines the single
+ `LIGHTSPEED_PLATFORM_API_KEY` to that endpoint. Tests assert this restriction.
+- The existing [`/adopt` route](../../../platform/backend/src/routes/universes.ts)
+ links an existing universe on that configured deployment. It accepts no
+ separate runtime endpoint or credential.
+- Runtime `single` auth mode rejects Authorization, universe and actor headers,
+ while Platform member calls send all three. Single-universe ownership is not
+ the same thing as the existing unauthenticated single mode.
+- Platform feature switches hide UI; they are not runtime capability checks.
+ The current API handshake does not advertise bots/channels/OAuth/local-tool
+ availability at the granularity this needs.
+
+Attaching local universes therefore needs:
+
+1. An explicit runtime connection with an endpoint, bound credential reference,
+ universe identity, version/capabilities and connection status. Preserve the
+ key-to-endpoint restriction when supporting multiple connections.
+2. Connectivity from the Platform backend: a reachable endpoint or an outbound
+ tunnel/relay. A hosted Platform cannot reach a laptop's loopback listener
+ just because the user's browser can.
+3. Authentication that preserves the Platform's member checks and actor
+ attribution, scoped to the one local universe.
+4. Capability-aware UI and explicit unsupported-method responses for features
+ excluded from local v1. Public session wire compatibility alone is not
+ enough to make the whole current UI work unchanged.
+5. Attach/detach and ownership rules distinct from provisioning, repairing or
+ deleting a managed runtime. Also settle UUID/slug conflicts: the Platform
+ currently expects globally unique universe IDs and cached slugs, while
+ runtime slugs are deployment-local.
+
+A directly reachable local runtime is a useful first adoption prototype; it
+can validate adoption before choosing a tunnel architecture. Platform
+adoption is part of the intended outcome, not a reason to put the Platform
+server inside the one-process local runtime.
+
+## Workflow tools and SDK-owned local functions: deferred beyond v1
+
+**Scope decision, 2026-10-06:** local v1 does not expose external/custom workflow
+tools, externally managed sessions or SDK callback functions. It rejects
+`session/managed/start` and arbitrary external workflow bindings clearly, with
+capability reporting that lets clients avoid offering them. An SDK can still
+start, read, steer and cancel ordinary sessions through the public API.
+
+This leaves built-in sub-agents and environment jobs in scope. The current
+[session preparation](../../../crates/temporal-runtime/src/gateway/service/session_preparation.rs)
+injects these as system bindings, and the
+[harness](../../../crates/harness/src/core/components/workflow_tool.rs) keeps
+those bindings separate from the immutable managed-session declaration. Retain
+their generic invocation/completion machinery in the shared core and interpret
+it locally; do not replace it with feature-specific transports. Existing MCP
+integration also remains in scope.
+
+### Later direction: an SDK process hosts tools and/or controls sessions
+
+An SDK application could launch local Lightspeed, establish an authenticated
+connection, register functions and optionally act as a session's lifecycle
+controller. Lightspeed still owns the durable universe, session state,
+orchestration and model execution. User function code executes in the SDK
+application; the runtime sends invocations and receives results through a
+protocol. This preserves one runtime process owning the universe while allowing
+separate application/tool processes.
+
+Launching the process is a packaging convenience, not the source of tool
+authority or session ownership. Design the protocol so an authorized SDK host
+could also attach to an already-running universe. Tool hosting and lifecycle
+control should be independent capabilities: a host may supply functions, manage
+sessions, or do both. The current managed-session contract already separates
+tool receivers from an optional lifecycle controller.
+
+Proposals to evaluate after v1:
+
+- **Registration and admission:** universe registration makes a tool available
+ for selection, not automatically enabled everywhere. A session explicitly
+ admits selected definitions/bindings. Session-private tools can be admitted
+ without publishing them to a universe-wide catalog.
+- **Stable bindings:** pin the schema/version, completion semantics and logical
+ host identity when a session admits a tool. Reconnecting a host or updating
+ its registry must not silently replace an existing session's tool contract.
+ Decide separately whether later versions allow explicit binding changes;
+ today's managed declarations are immutable.
+- **Execution protocol:** carry invocation/attempt IDs, arguments or CAS
+ references, deadlines, cancellation and correlated terminal results. Bind
+ completion authority to the admitted host and acknowledge persisted results
+ so retransmission can be deduplicated. SDK languages wrap the same protocol.
+- **Lifecycle control:** declare which sessions the SDK manages and deliver
+ their lifecycle notifications durably. Ordinary run submission, steering and
+ cancellation can reuse the API; registration alone grants no extra authority
+ over other sessions.
+- **Host loss:** preserve durable sessions and bindings, while interrupted
+ callback attempts follow the agreed restart policy. Reconnection restores
+ availability, not abandoned invocations. Distinguish stable host identity from
+ a live connection and reject completions from retired attempts. Decide the
+ disconnect grace period and whether controller-dependent work pauses when
+ its SDK controller is unavailable.
+
+Reuse the generic workflow-tool semantics rather than introducing another tool
+system. The harness already owns schemas, invocation IDs, completion promises,
+cancellation and deduplication without importing Temporal. Its `WorkflowStartRef`
+is substrate-neutral, but today's
+[start adapter](../../../crates/temporal-runtime/src/worker/activities/workflow_tools.rs)
+interprets recipes as Temporal workflow types and task queues. A connected SDK
+host is a natural candidate for a bound receiver with a new transport; starting
+a new host per invocation would be a separate lifecycle choice.
+
+The existing `bound + pull` mode permits only `accepted` completion, so it is not
+already a request/result callback protocol. A local binding needs authenticated
+completion, cancellation and recovery semantics for joined results/promises.
+The transport (for example an inherited connection, local socket or loopback
+RPC), registration API and JavaScript/Python SDK surface remain undecided. This
+future work does not block v1 or require language-specific branches in the
+harness or stable session worker.
+
+## How much code can be shared?
+
+### Measurement and existing reusable code
+
+The following inventory was measured on 2026-10-06 with `cloc` over tracked
+Rust `src` files selected by `git ls-files`. Counts exclude comments and blank
+lines, but **include inline tests and `src` test modules**; they are not
+production-only LOC. Integration tests, generated non-Rust contracts and the
+Platform frontend are outside this inventory. Sharing estimates are code-review
+judgments, not an automated classification or a measured final design.
+
+| Already separate from Temporal | Approximate code lines | Reuse assessment |
+| --- | ---: | --- |
+| `harness` | 29.7k | Reuse deterministic session state, admissions and `CoreAgentDrive`; keep I/O outside it. |
+| `llm-runtime` + `llm-clients` | 20.4k | Reuse provider-native request/response adapters and model execution. |
+| `tools` | 24.7k | Reuse tool definitions, VFS, skills, schemas and environment-protocol adapters. |
+| `api` + `api-projection` | 16.8k | Reuse public wire contracts, dispatch and projections; expose local availability accurately. |
+| `vfs`, `environments`, `environment-client`, `environment-protocol`, `mcp`, `profiles` | 9.1k | Reuse domain contracts and adapters; implement persistent local stores and runtime wiring. |
+| `environment-daemon` | 11.1k | Reuse the execution service; packaging and its lifecycle remain to be chosen. |
+| `cli` | 21.5k | Reuse HTTP client/TUI; add local launch/attach behavior if wanted. |
+
+The first five rows are about **101k lines already outside the two Temporal
+crates**. This is an inventory of reusable components, not a promise that every
+line or feature ships in local v1. Auth also has reusable abstractions, but
+credential mode decisions determine how much of it is applicable.
+
+[`SessionRunner`](../../../crates/test-support/src/runner/drive.rs), used by
+evals, proves in-process execution is possible. It does not replace production
+orchestration: it awaits model calls and tool batches without the hosted
+admission racing and progressive execution behavior. Ultimately evals and
+tests should exercise the shared orchestrator, rather than promoting this
+runner unchanged as the local implementation.
+
+### Extracting `temporal-workflow`
+
+The crate has about **17.7k source code lines**. Roughly 6.6k belong to bots and
+channels and can stay hosted-only. The session tree is about 7.5k, including
+2.0k in its separate test module plus more inline tests. Preparation/rehydration,
+sub-agent/environment-job/transcription workflows and shared DTOs/activity
+declarations bring the session-related review surface to about **10.6k**.
+
+| Area | Shared code to extract | What remains substrate-specific |
| --- | --- | --- |
-| `harness` (30k lines) incl. `CoreAgentDrive` | Deterministic, zero Temporal dependency. Emits `AppendEvents`, `GenerateLlm`, `CompactContext`, `InvokeTools`, `Idle`, `Closed`. | Yes |
-| `SessionRunner` in `test-support` (2.5k lines) | Substrate-neutral loop: `drive_until_quiescent` fulfils LLM, compaction and tool actions in-process. Used by `eval` and replay tests. | Yes, after promoting it out of test-support |
-| `llm-runtime` + `llm-clients` (31k lines) | `impl CoreAgentLlm for LlmRuntime`; Anthropic, OpenAI Responses, Completions. | Yes |
-| `tools` (27k lines) | `InlineToolRuntime`, local and scoped filesystem tools, VFS, skills. | Mostly. The local process executor is a one-line placeholder, so there is no in-process shell tool. |
-| `store-fs` (1.8k lines) | Real filesystem CAS (`.lightspeed/cas/sha256/...`), VFS catalog, partial `FsSessionStore` (no listing, metadata, or cross-process lock). Unused in production. | CAS yes; session store needs finishing |
-| In-memory stores | Session, blob, environments, auth, bots, channels, MCP registry. | Tests and ephemeral runs only |
-| `environment-daemon` (envd, 12k lines) | Runs on a laptop today (`./dev.sh runtime` starts one on `127.0.0.1:19091`). | Yes, embedded or as a sidecar |
-| `bots`, `channels` domain crates | Pure state and policy; `controller/state.rs` (2.5k lines) has zero Temporal references. | Logic yes; the workflow shells around it no |
-| `cli` (24k lines) | Pure HTTP client of the hosted gateway (`HttpAgentApi`). | The TUI yes; needs a local backend behind it |
-| `platform/web` | Talks to the runtime only through the generated API client; the browser demo already swaps in an in-browser backend. | Yes, if a local runtime serves the same API |
-
-Two dependencies are not Temporal but still block a local install: PostgreSQL
-(`store-pg`, 18k lines, 10 migrations) and S3-compatible object storage
-(optional; small blobs are already inlined in Postgres). There is no SQLite
-anywhere in the tree. The [first-class runtime CLI](../p183-first-class-runtime-cli.md)
-work explicitly put "no embedded database or runtime alternative" out of scope.
-
-## Four ways to get there
-
-The options differ mainly in where the session orchestration lives: today it is
-about 20k lines of Rust written directly against the Temporal SDK, with no
-abstraction between it and the SDK.
-
-| Option | What it means | Hosted runtime | Local install | Rough effort | Main risk |
-| --- | --- | --- | --- | --- | --- |
-| **A. Bundle the current stack** | A launcher starts the Temporal dev server (SQLite-backed), an embedded or managed Postgres, envd and `lightspeed-runtime` as subprocesses behind one command. | Unchanged | One command, but Go + Postgres binaries, several processes, and slow startup | 2–4 weeks | Feels like a server install, not a CLI tool, so it may not fix adoption |
-| **B. Two runtimes** | Keep the Temporal runtime. Build a separate local runtime around `SessionRunner`, SQLite and the filesystem CAS that serves the same public API. | Unchanged | Single binary, no services | 2–3 months for interactive sessions | Orchestration semantics (admission, steering, cancel, promises, sub-agents) are re-implemented and drift from the hosted behaviour |
-| **C. One orchestration core, two substrates** | Do for the session workflow what the engine did for the agent loop: move admission racing, preparation, promise polling, emission delivery and the watchdog into a sans-IO orchestrator. Temporal and a local tokio/SQLite substrate each interpret it. | Temporal stays, behind a thinner shell | Single binary, no services | 3–5 months, mostly refactoring the hosted path first | A large refactor of working, live-validated code; Temporal's determinism rules (e.g. no custom wakers) constrain the shared design |
-| **D. Drop Temporal everywhere** | Option C, plus a Postgres-backed durable substrate (inbox, outbox, timers, leases) replaces Temporal in hosted too. | Postgres-only; we own scheduling, leases and failover | Same binary with SQLite | 6+ months | We take on the distributed-systems work Temporal does today: worker leases, failover, timer sweeps, at-least-once delivery, schedules |
-
-Option C is the preferred direction. Option B is the fastest route to a real
-local product, but every orchestration feature then has to be built twice and
-the two runtimes drift. Option C costs more up front and leaves a single
-definition of session behaviour; it is also the only path that keeps Option D
-open later without committing to it now. Option A is worth a short spike only
-to test whether "one command" alone moves adoption.
-
-### Option C pays off without a local runtime
-
-Lifting orchestration out of the Temporal workflow improves the hosted runtime
-even if no local runtime follows:
-
-- **Testability.** Admission racing, preparation, promise polling, emission
- retry, the cancel watchdog and continue-as-new gating are today testable only
- through Temporal, and some failures (such as the custom-waker restriction,
- TMPRL1100) surface only in live suites. As a plain state machine they get
- fast unit tests and replay vectors, as the engine already has.
-- **Smaller determinism surface.** Only the thin interpreter has to obey
- Temporal's workflow rules, not about 20k lines of orchestration.
-- **Less SDK exposure.** The Temporal Rust SDK is at 0.4.0. A thinner shell
- limits how much code each SDK upgrade touches.
-- **One copy of session behaviour.** `test-support`'s `SessionRunner` already
- duplicates hosted behaviour by hand (its prompt-refresh fallback "mirrors the
- hosted product"). With a shared `sessions` crate, eval and tests run the
- production orchestration.
-- **Proven pattern.** `bots::controller::state` is already a pure state machine
- inside a Temporal shell; sessions would follow the same pattern.
-
-The cost is a refactor of live-validated code. It pays back because session
-orchestration keeps changing: most recent roadmap items touched it.
-
-## Where a local runtime plugs in
+| Preparation, rehydration and status | Candidate validation, commit decisions, state reconstruction, receipts and readiness semantics | Store calls, effect completion and Temporal continuation wiring |
+| Admissions, active run and waits | Admission ordering/eligibility, run-slot policy, wake decisions, cancellation and stale-result rules | Signal/channel receipt, timers, executor racing and cancellation primitives |
+| Tool batches | Progressive dispatch policy and per-call effect identity | Futures/activities and transport execution |
+| Promise sources and workflow starts | Deadlines, retry decisions, invocation identity, completion/delivery bookkeeping | Temporal workflow start/describe/query/signal or local execution registry |
+| Sub-agent and environment-job orchestration | Child preparation, supervision, terminal results, cleanup policy | Temporal shells or local tasks using the same environment/service adapters |
+
+A plausible shared core is **roughly 3–5k lines of orchestration and contracts
+after reshaping**, plus associated conformance coverage. This is a design-size
+estimate; it is not a claim that 3–5k current lines can simply be moved.
+[`preparation_candidate`](../../../crates/temporal-workflow/src/workflows/session/preparation_candidate.rs),
+`session_preparation` and `rehydrate` contain easier pure extractions. The harder
+parts are
+[`control`](../../../crates/temporal-workflow/src/workflows/session/control.rs),
+[`tool_batches`](../../../crates/temporal-workflow/src/workflows/session/tool_batches.rs)
+and the preparation loop, where state decisions and async execution are mixed.
+
+There is already substantial unit coverage of pure helpers; the benefit is
+making whole orchestration interleavings testable without Temporal, not claiming
+that no unit tests exist today. Continue-as-new, workflow registration, history
+handling and Temporal retry/heartbeat mechanics stay in the hosted interpreter.
+The hosted bot/channel shells need not all be rewritten to deliver local v1.
+
+### Extracting `temporal-runtime`
+
+The runtime has about **53.5k source code lines**, of which the gateway subtree
+is about 21.7k. A roughly **20.7k-line candidate pool** consists of `SessionTools`,
+native MCP, secret resolution, environments, sub-agent services, checkpointing,
+credential injection and activity modules. Those categories do not overlap the
+gateway count, but neither pool is already completely neutral.
+
+Plan on **roughly 20–30k current source lines being extracted or adapted into
+shared services**, including their existing tests: approximately **40–55% of
+this crate**. The rest includes hosted bot/channel control, deployment setup,
+Temporal wrappers, and code that stays optional or needs a local replacement.
+This range is a refactoring footprint, not the number of new lines to write.
+
+The necessary boundaries are broader than a `SessionControl` trait:
+
+- [`GatewayAgentApi`](../../../crates/temporal-runtime/src/gateway/service/mod.rs)
+ owns both a Temporal `Client` and `Arc`. Separate session control
+ (start/admit/status/cancel/close) from shared API behavior, and replace
+ concrete PostgreSQL registry/access/preparation dependencies with appropriate
+ store/service interfaces. Authentication also names a PostgreSQL key store.
+- [`ActivityState`](../../../crates/temporal-runtime/src/worker/activities/state.rs)
+ already uses many store, LLM and tool traits, but preparation still holds
+ `PgStore`. Extract effect services with neutral requests/results and crate-local
+ error types. Keep `ActivityError`, retries, heartbeats and cancellation context
+ in Temporal wrappers.
+- [`SessionTools`](../../../crates/temporal-runtime/src/worker/session_tools.rs),
+ environment resolution and native MCP contain much of the shared execution
+ behavior. They need dependency cleanup rather than a second implementation.
+ [`SubagentChildRuntime`](../../../crates/temporal-runtime/src/subagents.rs)
+ is already a useful seam for replacing the child-session backend.
+- Some request/response DTOs and helper functions live in `temporal-workflow`
+ even when they express neutral work. Move them with their shared service
+ contracts so local code does not depend on the Temporal crate just for types.
+- Reapers, environment reconciliation and CAS collection already run as Tokio
+ loops, but their stores and some workflow operations are hosted-specific.
+ Reuse policy and services; do not describe these loops as plug-in local code.
+
+Most of the core agent behavior should therefore remain one implementation.
+The work is concentrated in extracting orchestration/services and adding a new
+storage/execution substrate. A percentage of the *entire future local product*
+would be misleading before its new SQLite, recovery, Platform and tool-host code
+exists; the measured component sizes and extraction ranges above are the more
+useful planning quantities.
+
+## Proposed shared architecture
```mermaid
flowchart TD
- subgraph Clients
- CLI[lightspeed CLI TUI]
- Web[Web UI]
- SDK[API clients and SDKs]
- end
- subgraph Shared["Shared, runtime-neutral"]
- API["Public API AgentApiService + JSON-RPC"]
- Orch["sessions (new) sans-IO orchestration: admissions, awaits, promises, sub-agents"]
- Engine["harness and adapters CoreAgentDrive, llm-runtime, tools, MCP"]
- end
- subgraph Hosted["Hosted substrate (today)"]
- Temporal["Temporal durable workflows"]
- PG[("PostgreSQL store-pg")]
- S3[("S3 CAS object storage")]
- RemoteEnvd["Remote envd via environment gateway"]
- HostedOnly["Hosted only: bots, channels, schedules"]
- end
- subgraph Local["Local substrate (new)"]
- Tasks["Session tasks (new) tokio, per session"]
- SQLite[("SQLite (new) store-sqlite")]
- FsCas[("Filesystem CAS store-fs, exists")]
- EmbeddedEnvd["Embedded envd (new) local shell, files"]
- OneBinary["One binary, data in ~/.lightspeed"]
- end
- Clients --> Shared
- Shared --> Hosted
- Shared --> Local
+ Clients[CLI and SDK clients] --> API[Shared public API services]
+ Platform[Platform: hosted or attached universe] --> API
+ API --> Sessions[Shared sessions orchestration]
+ Sessions --> Harness[Deterministic harness]
+ Sessions --> Effects[Shared effect services: LLM, tools, MCP, environments]
+ Sessions --> Hosted[Temporal interpreter]
+ Sessions --> Local[Tokio interpreter: one universe process]
+ Hosted --> PG[(store-pg: PostgreSQL and CAS)]
+ Local --> FS[(store-fs: SQLite and filesystem CAS)]
+ Effects --> Envd[Environment protocol: local or remote envd]
```
-The local runtime keeps the public API, the engine and the adapters as they
-are. The new work is the extracted orchestrator and the substrate under it. The
-CLI and web UI need no changes to talk to either runtime.
+These are logical boundaries, not separate local services. Proposed crate roles:
-### Crate layout
-
-The orchestration gets its own crate rather than living in the engine:
-
-| Crate | Role |
+| Crate / boundary | Responsibility |
+| --- | --- |
+| `harness` | Existing event-sourced agent loop; no infrastructure I/O. |
+| `sessions` (new) | Pure session orchestration state, ordered inputs and effect intents above the harness. |
+| Shared runtime services (name/split TBD) | API service behavior, preparation, effect execution, environment/MCP integration and neutral contracts. I/O is allowed here. |
+| `store-fs` | Local SQLite records, CAS, migrations, ownership and recovery storage. |
+| `store-pg` | Existing hosted store. |
+| `temporal-workflow` | Temporal orchestration interpreter, workflow registration, hosted controller shells. |
+| `temporal-runtime` | Hosted composition, Temporal activities/client adapters and roles. |
+| `local-runtime` (provisional) | Single-universe composition, Tokio interpreter and shared API/services. Binary/CLI packaging remains open. |
+
+Keep `sessions` above `harness` because their state has different sources of
+truth. Harness state is reconstructed from recorded session events;
+orchestration also tracks in-flight admissions, effects and delivery. This
+separation preserves the harness replay invariant and leaves room to supervise
+an external harness through the same session machinery later.
+
+### Prefer a synchronous core with async interpreters
+
+`sessions` should consume ordered inputs such as admission arrival, effect
+completion and observed time, and emit intents such as append events, execute
+or cancel work, deliver an envelope and arrange a wake. Effects stay outside
+that core. Temporal rollover/history policy belongs to the Temporal interpreter,
+not to every local session's state machine.
+
+This makes cancellation/admission races explicit and testable, and permits
+serializable orchestration state. Shared code does not need to run futures
+under both Temporal's workflow executor and Tokio. Temporal's workflow rules
+still apply to the interpreter, and changes to core decisions still need
+workflow compatibility/replay review.
+
+A shared async implementation behind a host trait remains an alternative to
+compare during the spike. Port one slice—the wait loop plus admission racing
+against a running effect—and compare complexity and tests. Avoid forcing every
+linear I/O helper into a state machine; ordinary shared effect services can
+remain async. The existing pure bot-controller policy inside its Temporal shell
+is a useful precedent, without expanding local v1 to include bots.
+
+## Alternatives and effort
+
+| Option | Assessment under the clarified scope |
| --- | --- |
-| `harness` (renamed from `harness`) | Lightspeed's native agent loop: events, session state, context, tool planning, `CoreAgentDrive`. Deterministic and event-sourced. |
-| `sessions` (new) | Sans-IO session orchestration: admission inbox, run slot, preparation steps, promise sources, emission outbox, workflow-start dedupe, wake computation, cancel watchdog. Depends on `harness`. |
-| `temporal-workflow` | Thin interpreters that run `sessions`, `bots` and `channels` state machines on Temporal. |
-| `temporal-runtime` (renamed from `temporal-runtime`) | Activities, roles and Temporal wiring. |
-| `local-runtime` (later) | Tokio interpreter of `sessions` over SQLite, filesystem CAS and embedded envd. |
-
-Why `sessions` is separate from `harness`:
-
-- **Different state models.** Harness state is reduced from the event log;
- replaying the log reconstructs it. Orchestration state is in-flight
- bookkeeping (pending admissions, undelivered emissions, start dedupe, timers)
- that is carried across continue-as-new and partly derived from harness state.
- Mixing them blurs the "replay the log, get the state" invariant.
-- **Vocabulary.** [External harness sessions](../p185-external-harness-sessions.md)
- uses "harness" for what owns model calls, context, tools and the inner loop,
- and gives Lightspeed admission, orchestration, access policy and supervision.
- That maps onto `harness` and `sessions` respectively. Keeping orchestration
- above the harness also leaves room to drive an external harness through the
- same `sessions` machinery later.
-- **Naming convention.** `bots`, `channels` and `environments` already hold
- their domain's pure state machines and policy; `sessions` matches.
-
-A later split could also move the gateway's service layer (about 23k lines,
-little Temporal coupling) out of `temporal-runtime` into its own crate once a
-`SessionControl` trait exists, so both runtimes serve the API from the same
-code. That is independent of the renames.
-
-### A sync core with async interpreters
-
-`sessions` should be a synchronous state machine: inputs such as "admission
-arrived", "activity completed" or "timer fired"; outputs such as start or
-cancel an activity, set a timer, signal or start a workflow, roll over. Each
-runtime provides a small async interpreter that owns the racing and the
-plumbing. Shared async code generic over a host trait (`start_activity`,
-`timer`, `next_admission`, `select`) is a real alternative, but the sync core
-is preferred because:
-
-- **Temporal's rules stay out of shared code.** Under Temporal, await order,
- `select` and combinators must be deterministic on its executor. Shared async
- code would have to obey that even on tokio, where nothing enforces it, and
- violations surface only under Temporal. A machine without futures cannot
- violate them.
-- **Racing becomes explicit input.** The hard behaviour is what happens when
- an admission, cancel or approval arrives during a model or tool call. In
- async code that is a `select` whose semantics differ between executors
- (branch order; dropping a future versus Temporal's explicit cancellation and
- waiting for its result). As ordered inputs, interleavings are testable,
- including the awkward ones.
-- **State is already a value.** Continue-as-new carry, and a local restart,
- need the orchestration state as a serializable struct. Async code keeps it in
- future stack frames and needs hand-extracted carry state, which is what
- `AgentSessionContinuationState` does today.
-- **Cheaper tests.** Feed input sequences, assert emitted commands; no fake
- executor or timing.
-
-Costs: state machines invert control, so linear multi-step flows (such as the
-preparation retry loop) read worse than top-to-bottom async code. Versioning
-is not avoided either: changing what the machine decides still changes the
-commands in Temporal history. The bot controller's split is the working
-precedent: decisions in a sync core, racing in a small async shell. Linear
-steps that never race can stay async in the interpreter rather than being
-forced into states.
-
-To settle it with evidence rather than preference, the first step of the
-extraction ports one slice both ways (the wait loop plus admission racing
-against a running activity) and compares the code and its tests.
-
-## What a local version would take
-
-A useful local v1 is about eight work items, assuming its scope is interactive
-sessions only. Bots, chat channels, schedules, Platform login and
-multi-universe tenancy stay hosted features. Those are always-on, multi-user
-concerns, and they account for most of the Temporal surface we would otherwise
-have to replace (bot controller, trigger fires, conversations, Schedules,
-connector task queues).
-
-In scope for local v1: sessions and runs; steer, cancel and approvals; local
-files and shell; MCP; skills and profiles; sub-agents; resuming a session days
-later. The public API stays identical, so the CLI TUI and the web UI work
-unchanged.
-
-The effort figures are engineer-weeks for someone who knows the codebase, at
-review-level confidence. The "Effort" column assumes Option C; the last column
-notes where Option B differs.
-
-| # | Work item | What exists | What is new | Effort | Option B instead |
-| --- | --- | --- | --- | --- | --- |
-| 1 | Local backend behind the public API | `AgentApiService` trait (about 119 methods, many with "unavailable" defaults) and a generic `dispatch_json_rpc` in `crates/api` | `LocalAgentApi` implementing the session, run, context, events, VFS and models subset. The CLI calls it in-process; `lightspeed serve` exposes it to the web UI. | 3–4 | Same |
-| 2 | Session orchestrator | `CoreAgentDrive`; `SessionRunner` (synchronous drive-until-quiescent); the Temporal session workflow | Admissions handled while a model or tool call runs (steer, cancel, approvals), awaits and timers, promises, queued runs, sub-agents spawned in-process | 8–12, which includes reshaping the hosted workflow | 5–7 on its own, then every later feature built twice |
-| 3 | SQLite store | Store traits in `harness`, `vfs`, `environments`, `auth`, `mcp`, `profiles`; `store-pg` as the reference | A `store-sqlite` crate with one-file migrations. jsonb containment becomes `json_each` or filtering in the app; `text[]` becomes JSON; advisory locks become a single-writer process lock. | 3–5 | Same |
-| 4 | Filesystem CAS + collection | `FsBlobStore` (sha256 layout) | Wire it in; reference roots and sweeps without Postgres | 1 | Same |
-| 5 | Local shell and process tools | envd (12k lines) runs on laptops today; the in-process `ProcessExecutor` is a placeholder | Embed envd as a library over an in-memory transport, or spawn it as a sidecar. Default the active environment to the working directory. | 2–3 | Same |
-| 6 | Permission model for a user's own machine | MCP approvals (`AwaitingApproval`, parked runs) | Approvals for shell and writes outside the workspace, with allow rules per session and per directory. Codex and Claude Code users expect this. | 2–3 | Same |
-| 7 | Identity and configuration | Single-user auth mode; model defaults; `connect` profiles in the CLI | An implicit local universe and actor; provider keys from environment variables or the OS keychain; a data directory such as `~/.lightspeed` | 1–2 | Same |
-| 8 | Packaging and tests | Release pipeline for envd (musl builds); replay vectors | One `lightspeed` binary (TUI by default, plus `serve`); the substrate-neutral test suite run against both substrates | 2–3 | Tests per runtime |
-
-Total: about 22–33 engineer-weeks for Option C, or 19–28 for Option B before
-the duplication cost. With two people in parallel, that is roughly three to
-four months of calendar time; the spike in the sequence below runs alongside
-the first phase.
-
-Two items are mostly mechanical. The gateway's Temporal calls are concentrated
-in `workflow.rs` and `session_lifecycle.rs` (start, `submit_admissions`,
-`status` polling, describe, terminate). Putting them behind a small
-`SessionControl` trait would let the hosted gateway and the local backend share
-most of the 19k-line service layer. The activity helpers return Temporal error
-types (`ActivityError`, `ApplicationFailure`), so they need a neutral error
-type before the local substrate can call them.
-
-## Risks and open questions
-
-The biggest risk is not the database swap. It is that two runtimes slowly
-disagree about what a session does.
-
-**Risks**
-
-- **Semantic drift.** Steering, cancellation, promise deadlines and sub-agent
- budgets have been hardened against live Temporal suites. A second runtime
- needs the same conformance suite, written against the substrate-neutral API
- and run against both substrates in CI.
-- **Crash and sleep semantics.** Today Temporal retries an activity interrupted
- by a worker restart. Locally, a closed laptop lid or a killed process leaves
- an LLM call or shell command half-done. We need an explicit rule, probably:
- retry model calls, and mark interrupted shell commands as interrupted rather
- than re-running them. This overlaps the parked idempotency work for tools.
-- **Two writers on one data directory.** Two CLI windows on the same session
- need either a file lock per session or one local daemon that owns the store.
- Codex and Claude Code avoid this by being one process per conversation.
-- **Every schema change twice.** Each `store-pg` migration needs a SQLite twin.
- The release metadata currently pins one schema revision.
-- **Shared orchestrator under Temporal's rules.** The shared code must stay
- deterministic and avoid custom wakers (the TMPRL1100 constraint). The sync
- core described under [A sync core with async interpreters](#a-sync-core-with-async-interpreters)
- is the mitigation; the risk is that awkward flows get forced into states.
-- **Plugin contract.** The workflow-tool contract is defined in Temporal terms
- (signals, queries, task queues). External plugin workflows would not run
- locally unless the contract gets a second, non-Temporal binding.
-
-**Open questions**
-
-- [ ] Is the adoption blocker really infrastructure, or also the first-run
- experience (keys, environments, profiles)? Option A answers this cheaply.
-- [ ] Should a local session be movable to hosted, e.g. `lightspeed push`?
- Sessions are event logs plus CAS, so export and import look plausible, and
- that would make local an on-ramp to hosted rather than a fork.
-- [ ] What is Lightspeed's differentiator against Codex, Claude Code and Pi on
- a laptop? Candidates: provider-native multi-model sessions, durable
- resumable sessions, VFS workspaces, sub-agents, and the same agent later
- running as a hosted bot.
-- [ ] Should local sandbox shell commands (macOS seatbelt, Linux namespaces),
- or rely on approvals alone at first?
-- [ ] Would hosted ever drop Temporal (Option D)? If not, Option C's main
- payoff is a single definition of behaviour, not portability.
-
-## Suggested sequence
-
-Because the extraction is worth doing on its own, it does not have to wait for
-the spike; the spike gates only the local substrate. Durations are calendar
-weeks for two engineers.
-
-| Phase | Duration | Work | Gate after |
-| --- | --- | --- | --- |
-| 0 · Spike | 2–4 weeks, in parallel with phase 1 | CLI over `SessionRunner`; in-memory or SQLite store; embedded envd; try with design partners | **Go / no-go on local:** spike used on real tasks |
-| 1 · Extract `sessions` | 5–7 weeks | Port one slice both ways and pick sync core or async host trait; sans-IO orchestrator; thin Temporal interpreter; `SessionControl` trait; neutral activity errors; `harness` → `harness` and `temporal-runtime` → `temporal-runtime` renames | **Hosted unchanged:** live Temporal suites green on the thin interpreter |
-| 2 · Local substrate | 5–7 weeks | SQLite store; tokio interpreter of `sessions`; shell approvals; one-binary packaging | **Parity:** conformance suite green on both substrates |
-| 3 · Beta and bridge | 2–3 weeks | Public local release; push session to hosted; docs and onboarding | Then revisit Option D |
-
-The spike is throwaway-tolerant: wire the existing CLI to an in-process
-`SessionRunner` with an embedded envd and put it in front of a few design
-partners to test the adoption hypothesis. Meanwhile, extract `sessions` while
-hosted is its only consumer, so live suites prove nothing changed. If the spike
-does not land, phase 1 still stands on its own and phases 2 and 3 wait. If it
-does, the local substrate is built on the extracted core, and the spike's
-`SessionRunner` is replaced by the production orchestration.
+| A · Bundle Temporal and PostgreSQL | Could simplify launching today's product, but does not meet the single-process, SQLite local-runtime direction. |
+| B · Independently implement local orchestration | Faster initial demo, but duplicates admission, cancellation, promises and sub-agent semantics. Not the chosen direction. |
+| C · One orchestration core, two substrates | Preferred. Extract shared session/services first, then add local execution/storage while retaining Temporal hosted. |
+| D · Replace hosted Temporal too | Separate, much larger distributed-systems project. Not required for this outcome. |
+
+The earlier 22–33 engineer-week estimate covered a narrower local session
+runtime and assumed more gateway portability than the code currently provides.
+Keep it as a historical planning reference, not a new commitment. It also did
+not establish the cost of real multi-runtime Platform adoption, complete local
+crash recovery, OAuth or an SDK callback/tool-host contract.
+
+Re-estimate after the first extraction and recovery spike. A credible plan must
+budget separately for:
+
+- shared orchestration, neutral contracts and hosted conformance;
+- gateway/effect service extraction, including PostgreSQL coupling;
+- SQLite records/migrations and filesystem CAS recovery/collection in `store-fs`;
+- local ownership, admission persistence, effect recovery and shutdown;
+- environment wiring and envd packaging;
+- env-based configuration, local identity and any selected credential exception;
+- Platform attachment, authentication, capability handling and connectivity;
+- release packaging and end-to-end parity tests.
+
+SDK local tools and externally managed sessions are excluded from the v1
+estimate. Full OAuth still needs an explicit scope decision. Session export/import
+to hosted is independent of Platform adoption and is not an assumed beta
+deliverable.
+
+## Open decisions
+
+- [ ] **Process/package boundary:** confirm that the API gateway stays inside
+ the owning runtime process. Choose embedded or sidecar/bundled envd and how
+ a CLI or SDK starts or connects to the process.
+- [ ] **Universe location:** explicit data directory, a default under the user's
+ home, project-local `.lightspeed`, or named universes with discoverable paths?
+ Recommendation to evaluate: persist an independent universe UUID and let a
+ project directory select a universe; do not derive its identity from cwd.
+ Moving a directory or opening two checkouts should have defined behavior.
+- [ ] **Host environment:** enable it by default or opt in; which filesystem
+ root and permission/sandbox policy? Keep this independent of universe/VFS
+ storage placement.
+- [ ] **Recovery storage mechanics:** exact durable admission point,
+ attempt/child-execution journal and outbox layout, retry-backoff persistence,
+ CAS recovery and backups. These implement the agreed restart policy above;
+ interrupted-tool and sub-agent behavior are no longer open decisions.
+- [ ] **Credentials/OAuth:** whether to allow persisted local credentials, where
+ to keep them, and how callbacks work. Decide runtime connection credentials
+ and envd identity separately from user/provider secrets.
+- [ ] **Platform attachment:** direct connectivity first or a tunnel/relay;
+ credential ownership; actor attribution; attach/detach/deletion semantics;
+ universe UUID and slug conflicts across deployments.
+- [ ] **Later SDK-host protocol (after v1):** tool registration/admission,
+ independent lifecycle control, transport, result acknowledgement and host-loss
+ behavior. Explore SDK launch and attach modes; this is not a v1 dependency.
+- [ ] **Capability edge cases:** explicitly enumerate the supported public API
+ subset, including standalone transcription and credential mutation methods.
+ Externally managed sessions and custom workflow-tool integrations are already
+ excluded from v1; advertise and reject unsupported methods consistently.
+
+## Suggested sequence and validation
+
+1. **Extraction/recovery spike.** Compare sync and async designs for one racing
+ slice; run it against both interpreters. Prototype one SQLite-backed universe
+ with filesystem CAS and an ordinary envd connection. Exercise a sub-agent and
+ kill the runtime around admission, append, invocation and completion commits.
+ Verify interrupted calls let the same run continue, completed sibling results
+ survive, parent/child identities and deadlines are retained, and stale
+ completions cannot overwrite recovery outcomes. Cover restarted model calls,
+ preserved approval waits, nested sub-agents, unreachable or still-running
+ remote executions, and a second crash during recovery itself.
+2. **Shared core and services, hosted first.** Extract `sessions`, neutral effect
+ contracts and shared API services incrementally while Temporal remains the
+ production interpreter. Reuse existing tests and add deterministic
+ interleaving/replay coverage; validate hosted behavior with the relevant
+ serialized live suites during implementation.
+3. **Complete the local substrate.** Implement the agreed store and recovery
+ contract, environment/credential mode and process lifecycle. Run the same
+ session conformance suite against hosted and local backends, with explicit
+ additional local crash tests and declared differences in guarantees. Verify
+ that public managed-session/custom workflow integrations are unavailable
+ while built-in sub-agents and environment jobs still work.
+4. **Platform adoption and packaging.** Attach an existing local universe with
+ its identity/data intact, then exercise member attribution, capabilities,
+ offline/reconnect behavior and detach. Choose connectivity and SDK packaging
+ based on the prototype; release only the agreed capability subset.
+
+The spike can run beside the first shared extraction. It should test the
+intended small universe deployment, rather than validate only a coding-agent
+CLI over the eval runner. The hosted extraction remains useful on its own:
+less SDK coupling, broader unit coverage and one definition of session behavior.
From e3d9af52ae69bcbe2f1899260551c437fa229f9b Mon Sep 17 00:00:00 2001
From: lb <542828+lukebuehler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 16:20:46 +0200
Subject: [PATCH 2/8] constrain contributor session config
---
clients/typescript/schema/api.schema.json | 158 +++++++++-
clients/typescript/src/generated/methods.ts | 74 ++++-
clients/typescript/src/generated/types.ts | 71 +++++
crates/api/contract/api-reference.md | 62 ++--
crates/api/contract/api.schema.json | 158 +++++++++-
crates/api/contract/methods.json | 84 +++--
crates/api/contract/openrpc.json | 248 +++++++++++++--
crates/api/src/access.rs | 51 ++-
crates/api/src/rpc.rs | 18 +-
.../src/gateway/service/controller.rs | 19 +-
.../access-and-security/people-and-roles.md | 21 +-
.../private-and-shared-work.md | 8 +-
.../profiles-and-instructions.md | 26 +-
.../using-lightspeed/sessions-and-runs.md | 35 ++-
...m-organizations-roles-and-unshared-work.md | 30 +-
platform/README.md | 13 +-
platform/backend/src/routes/messages.test.ts | 12 +-
platform/backend/src/routes/method-roles.ts | 16 +-
platform/backend/src/runtime-client.test.ts | 102 +++++-
platform/backend/src/runtime-client.ts | 43 ++-
.../configurator-mcp/src/generated/tools.ts | 8 +-
.../web/src/components/mcp/tool-picker.tsx | 11 +-
.../session/session-config-editor.tsx | 292 ++++++++++--------
.../session/session-config-readonly.test.tsx | 53 ++++
.../session-settings-permissions.test.tsx | 59 ++++
.../session/session-settings-sheet.tsx | 27 +-
platform/web/src/lib/permissions.test.tsx | 3 +
platform/web/src/lib/permissions.tsx | 18 +-
.../pages/ProfilesPage.permissions.test.tsx | 7 +-
platform/web/src/pages/ProfilesPage.tsx | 28 +-
.../pages/SessionsPage.permissions.test.tsx | 26 +-
platform/web/src/pages/SessionsPage.tsx | 59 ++--
32 files changed, 1506 insertions(+), 334 deletions(-)
create mode 100644 platform/web/src/components/session/session-config-readonly.test.tsx
create mode 100644 platform/web/src/components/session/session-settings-permissions.test.tsx
diff --git a/clients/typescript/schema/api.schema.json b/clients/typescript/schema/api.schema.json
index ef95aaa89..b826c8b0f 100644
--- a/clients/typescript/schema/api.schema.json
+++ b/clients/typescript/schema/api.schema.json
@@ -1186,6 +1186,40 @@
],
"type": "object"
},
+ "AgentApiOutcomeOfDeploymentSessionAuditListResponse": {
+ "properties": {
+ "notifications": {
+ "items": {
+ "$ref": "#/definitions/AgentNotification"
+ },
+ "type": "array"
+ },
+ "result": {
+ "$ref": "#/definitions/DeploymentSessionAuditListResponse"
+ }
+ },
+ "required": [
+ "result"
+ ],
+ "type": "object"
+ },
+ "AgentApiOutcomeOfDeploymentSessionPurgeResponse": {
+ "properties": {
+ "notifications": {
+ "items": {
+ "$ref": "#/definitions/AgentNotification"
+ },
+ "type": "array"
+ },
+ "result": {
+ "$ref": "#/definitions/DeploymentSessionPurgeResponse"
+ }
+ },
+ "required": [
+ "result"
+ ],
+ "type": "object"
+ },
"AgentApiOutcomeOfDeploymentUniverseCreateResponse": {
"properties": {
"notifications": {
@@ -8856,6 +8890,72 @@
],
"type": "object"
},
+ "DeploymentSessionAuditListParams": {
+ "additionalProperties": false,
+ "properties": {
+ "limit": {
+ "description": "Most recent records, between 1 and 1000; defaults to 100.",
+ "format": "uint32",
+ "minimum": 0,
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "universeId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "DeploymentSessionAuditListResponse": {
+ "properties": {
+ "events": {
+ "items": {
+ "$ref": "#/definitions/SessionAuditEvent"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "events"
+ ],
+ "type": "object"
+ },
+ "DeploymentSessionPurgeParams": {
+ "additionalProperties": false,
+ "properties": {
+ "sessionId": {
+ "description": "An already deleted session; includes its deleted descendants.",
+ "type": "string"
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "universeId",
+ "sessionId"
+ ],
+ "type": "object"
+ },
+ "DeploymentSessionPurgeResponse": {
+ "properties": {
+ "purgedSessionIds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "purgedSessionIds"
+ ],
+ "type": "object"
+ },
"DeploymentUniverseCreateParams": {
"properties": {
"slug": {
@@ -11998,7 +12098,8 @@
"mcp",
"bots",
"deployment/universes",
- "deployment/api-keys"
+ "deployment/api-keys",
+ "deployment/sessions"
],
"type": "string"
},
@@ -12007,6 +12108,11 @@
"description": "`session/*`, `blobs/read` and `blobs/has`.",
"type": "string"
},
+ {
+ "const": "session/delete",
+ "description": "Destructive session operations and deletion retention.",
+ "type": "string"
+ },
{
"const": "blobs/put",
"description": "`blobs/put` alone, so connectors can upload attachments without\nreading sessions.",
@@ -13763,6 +13869,54 @@
}
]
},
+ "SessionAuditEvent": {
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "affectedSessionIds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "attribution": {
+ "$ref": "#/definitions/Attribution"
+ },
+ "cause": {
+ "type": "string"
+ },
+ "createdAtMs": {
+ "format": "uint64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "outcome": {
+ "type": "string"
+ },
+ "sessionId": {
+ "type": "string"
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "universeId",
+ "sessionId",
+ "action",
+ "attribution",
+ "cause",
+ "affectedSessionIds",
+ "createdAtMs",
+ "outcome"
+ ],
+ "type": "object"
+ },
"SessionCloseParams": {
"properties": {
"force": {
@@ -17679,7 +17833,9 @@
"read",
"create_session",
"control_session",
+ "configure_session",
"stop_session",
+ "close_session",
"delete_session",
"create_profile",
"manage_profile",
diff --git a/clients/typescript/src/generated/methods.ts b/clients/typescript/src/generated/methods.ts
index 65fa84e07..fa458b3b7 100644
--- a/clients/typescript/src/generated/methods.ts
+++ b/clients/typescript/src/generated/methods.ts
@@ -125,6 +125,8 @@ export const METHODS = [
"channels/pairings/list",
"channels/pairings/delete",
"channels/conversations/read",
+ "deployment/sessions/audit/list",
+ "deployment/sessions/purge",
"deployment/environment-provider-bindings/list",
"deployment/universes/create",
"deployment/universes/list",
@@ -184,7 +186,7 @@ export const METHOD_INFO = {
},
"session/config/put": {
scope: "universe",
- access: {"action":"control_session","kind":"universe"},
+ access: {"action":"configure_session","kind":"universe"},
summary: "Replace session configuration",
description: "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op.",
},
@@ -196,19 +198,19 @@ export const METHOD_INFO = {
},
"session/metadata/put": {
scope: "universe",
- access: {"action":"control_session","kind":"universe"},
+ access: {"action":"configure_session","kind":"universe"},
summary: "Replace session metadata",
description: "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched.",
},
"session/retention/put": {
scope: "universe",
- access: {"action":"control_session","kind":"universe"},
+ access: {"action":"delete_session","kind":"universe"},
summary: "Replace session retention",
description: "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
},
"session/close": {
scope: "universe",
- access: {"action":"stop_session","kind":"universe"},
+ access: {"action":"close_session","kind":"universe"},
summary: "Close a session",
description: "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable.",
},
@@ -216,7 +218,7 @@ export const METHOD_INFO = {
scope: "universe",
access: {"action":"delete_session","kind":"universe"},
summary: "Delete closed sessions",
- description: "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.",
+ description: "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
},
"session/share": {
scope: "universe",
@@ -298,19 +300,19 @@ export const METHOD_INFO = {
},
"session/profiles/apply": {
scope: "universe",
- access: {"action":"control_session","kind":"universe"},
+ access: {"action":"configure_session","kind":"universe"},
summary: "Apply a profile to a session",
description: "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes.",
},
"session/environments/activate": {
scope: "universe",
- access: {"action":"control_session","kind":"universe"},
+ access: {"action":"configure_session","kind":"universe"},
summary: "Activate a session environment",
description: "Selects an attached, live universe environment for environment-targeted tools while the session is idle.",
},
"session/environments/deactivate": {
scope: "universe",
- access: {"action":"control_session","kind":"universe"},
+ access: {"action":"configure_session","kind":"universe"},
summary: "Deactivate the session environment",
description: "Clears active environment selection without changing or closing the universe environment.",
},
@@ -744,7 +746,7 @@ export const METHOD_INFO = {
scope: "universe",
access: {"action":"manage_bot","kind":"universe"},
summary: "Delete a bot",
- description: "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.",
+ description: "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.",
},
"bots/state/read": {
scope: "universe",
@@ -866,6 +868,18 @@ export const METHOD_INFO = {
summary: "Read a conversation snapshot",
description: "Queries the conversation workflow's live state for one chat, for debugging; absent when no workflow exists yet.",
},
+ "deployment/sessions/audit/list": {
+ scope: "deployment",
+ access: {"kind":"deployment"},
+ summary: "Read session lifecycle audit",
+ description: "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.",
+ },
+ "deployment/sessions/purge": {
+ scope: "deployment",
+ access: {"kind":"deployment"},
+ summary: "Permanently purge deleted sessions",
+ description: "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.",
+ },
"deployment/environment-provider-bindings/list": {
scope: "deployment",
access: {"kind":"deployment"},
@@ -1091,7 +1105,7 @@ export interface MethodMap {
/**
* Delete closed sessions
*
- * Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.
+ * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.
*/
"session/delete": {
params: Api.SessionDeleteParams;
@@ -1883,7 +1897,7 @@ export interface MethodMap {
/**
* Delete a bot
*
- * Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.
+ * Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.
*/
"bots/delete": {
params: Api.BotDeleteParams;
@@ -2069,6 +2083,24 @@ export interface MethodMap {
params: Api.ChannelConversationReadParams;
result: Api.AgentApiOutcomeOfChannelConversationReadResponse;
};
+ /**
+ * Read session lifecycle audit
+ *
+ * Returns durable lifecycle records, including deletion and purge evidence that survives session removal.
+ */
+ "deployment/sessions/audit/list": {
+ params: Api.DeploymentSessionAuditListParams;
+ result: Api.AgentApiOutcomeOfDeploymentSessionAuditListResponse;
+ };
+ /**
+ * Permanently purge deleted sessions
+ *
+ * Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.
+ */
+ "deployment/sessions/purge": {
+ params: Api.DeploymentSessionPurgeParams;
+ result: Api.AgentApiOutcomeOfDeploymentSessionPurgeResponse;
+ };
/**
* List a universe's deployment provider bindings
*
@@ -2332,7 +2364,7 @@ export const rpc = {
/**
* Delete closed sessions
*
- * Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.
+ * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.
*/
sessionDelete(client: RpcCaller, params: Api.SessionDeleteParams): Promise {
return client.call("session/delete", params);
@@ -3036,7 +3068,7 @@ export const rpc = {
/**
* Delete a bot
*
- * Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.
+ * Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.
*/
botsDelete(client: RpcCaller, params: Api.BotDeleteParams): Promise {
return client.call("bots/delete", params);
@@ -3201,6 +3233,22 @@ export const rpc = {
channelsConversationsRead(client: RpcCaller, params: Api.ChannelConversationReadParams): Promise {
return client.call("channels/conversations/read", params);
},
+ /**
+ * Read session lifecycle audit
+ *
+ * Returns durable lifecycle records, including deletion and purge evidence that survives session removal.
+ */
+ deploymentSessionsAuditList(client: RpcCaller, params: Api.DeploymentSessionAuditListParams): Promise {
+ return client.call("deployment/sessions/audit/list", params);
+ },
+ /**
+ * Permanently purge deleted sessions
+ *
+ * Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.
+ */
+ deploymentSessionsPurge(client: RpcCaller, params: Api.DeploymentSessionPurgeParams): Promise {
+ return client.call("deployment/sessions/purge", params);
+ },
/**
* List a universe's deployment provider bindings
*
diff --git a/clients/typescript/src/generated/types.ts b/clients/typescript/src/generated/types.ts
index 630fe1779..48e194552 100644
--- a/clients/typescript/src/generated/types.ts
+++ b/clients/typescript/src/generated/types.ts
@@ -1340,8 +1340,10 @@ export type MethodGroup =
| "bots"
| "deployment/universes"
| "deployment/api-keys"
+ | "deployment/sessions"
)
| "session"
+ | "session/delete"
| "blobs/put"
| "environments"
| "channels"
@@ -1817,7 +1819,9 @@ export type UniverseAction =
| "read"
| "create_session"
| "control_session"
+ | "configure_session"
| "stop_session"
+ | "close_session"
| "delete_session"
| "create_profile"
| "manage_profile"
@@ -4808,6 +4812,51 @@ export interface AgentApiOutcomeOfDeploymentProviderBindingPutResponse {
export interface DeploymentProviderBindingPutResponse {
binding: EnvironmentProviderBindingView;
}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "AgentApiOutcomeOfDeploymentSessionAuditListResponse".
+ */
+export interface AgentApiOutcomeOfDeploymentSessionAuditListResponse {
+ notifications?: AgentNotification[];
+ result: DeploymentSessionAuditListResponse;
+}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "DeploymentSessionAuditListResponse".
+ */
+export interface DeploymentSessionAuditListResponse {
+ events: SessionAuditEvent[];
+}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "SessionAuditEvent".
+ */
+export interface SessionAuditEvent {
+ action: string;
+ affectedSessionIds: string[];
+ attribution: Attribution;
+ cause: string;
+ createdAtMs: number;
+ id: string;
+ outcome: string;
+ sessionId: string;
+ universeId: string;
+}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "AgentApiOutcomeOfDeploymentSessionPurgeResponse".
+ */
+export interface AgentApiOutcomeOfDeploymentSessionPurgeResponse {
+ notifications?: AgentNotification[];
+ result: DeploymentSessionPurgeResponse;
+}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "DeploymentSessionPurgeResponse".
+ */
+export interface DeploymentSessionPurgeResponse {
+ purgedSessionIds: string[];
+}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
* via the `definition` "AgentApiOutcomeOfDeploymentUniverseCreateResponse".
@@ -7332,6 +7381,28 @@ export interface DeploymentProviderBindingPutParams {
status: EnvironmentProviderBindingStatusView;
universeId: string;
}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "DeploymentSessionAuditListParams".
+ */
+export interface DeploymentSessionAuditListParams {
+ /**
+ * Most recent records, between 1 and 1000; defaults to 100.
+ */
+ limit?: number | null;
+ universeId?: string | null;
+}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "DeploymentSessionPurgeParams".
+ */
+export interface DeploymentSessionPurgeParams {
+ /**
+ * An already deleted session; includes its deleted descendants.
+ */
+ sessionId: string;
+ universeId: string;
+}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
* via the `definition` "DeploymentUniverseCreateParams".
diff --git a/crates/api/contract/api-reference.md b/crates/api/contract/api-reference.md
index 5403403db..e6855a091 100644
--- a/crates/api/contract/api-reference.md
+++ b/crates/api/contract/api-reference.md
@@ -96,9 +96,9 @@ Returns a cursor-paginated summary list ordered by most recent update, optionall
Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op.
-- Access: `{"kind":"universe","action":"control_session"}`
+- Access: `{"kind":"universe","action":"configure_session"}`
- Group: `session`
-- Role: `contributor`
+- Role: `operator`
- Target: `sessionId`
- Params: `SessionConfigPutParams`
- Result: `AgentApiOutcome`
@@ -122,9 +122,9 @@ Sets the display name, or clears it when displayName is omitted.
Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched.
-- Access: `{"kind":"universe","action":"control_session"}`
+- Access: `{"kind":"universe","action":"configure_session"}`
- Group: `session`
-- Role: `contributor`
+- Role: `operator`
- Target: `sessionId`
- Params: `SessionMetadataPutParams`
- Result: `AgentApiOutcome`
@@ -135,9 +135,9 @@ Replaces the complete descriptive key/value map (bounded like session/start); an
Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.
-- Access: `{"kind":"universe","action":"control_session"}`
-- Group: `session`
-- Role: `contributor`
+- Access: `{"kind":"universe","action":"delete_session"}`
+- Group: `session/delete`
+- Role: `admin`
- Target: `sessionId`
- Params: `SessionRetentionPutParams`
- Result: `AgentApiOutcome`
@@ -148,7 +148,7 @@ Sets the positive close-relative automatic-deletion duration on a retention root
Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable.
-- Access: `{"kind":"universe","action":"stop_session"}`
+- Access: `{"kind":"universe","action":"close_session"}`
- Group: `session`
- Role: `contributor`
- Target: `sessionId`
@@ -159,11 +159,11 @@ Closes an idle session and detaches its environment bindings. Force mode cancels
**Delete closed sessions**
-Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.
+Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.
- Access: `{"kind":"universe","action":"delete_session"}`
-- Group: `session`
-- Role: `contributor`
+- Group: `session/delete`
+- Role: `admin`
- Target: `sessionId`
- Params: `SessionDeleteParams`
- Result: `AgentApiOutcome`
@@ -343,9 +343,9 @@ Returns separate VFS and environment catalogs with source, reference, availabili
Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes.
-- Access: `{"kind":"universe","action":"control_session"}`
+- Access: `{"kind":"universe","action":"configure_session"}`
- Group: `session`
-- Role: `contributor`
+- Role: `operator`
- Target: `sessionId`
- Params: `ProfileApplyParams`
- Result: `AgentApiOutcome`
@@ -356,9 +356,9 @@ Applies a named or inline profile's config, instructions, and environment setup
Selects an attached, live universe environment for environment-targeted tools while the session is idle.
-- Access: `{"kind":"universe","action":"control_session"}`
+- Access: `{"kind":"universe","action":"configure_session"}`
- Group: `session`
-- Role: `contributor`
+- Role: `operator`
- Target: `sessionId`
- Params: `SessionEnvironmentActivateParams`
- Result: `AgentApiOutcome`
@@ -369,9 +369,9 @@ Selects an attached, live universe environment for environment-targeted tools wh
Clears active environment selection without changing or closing the universe environment.
-- Access: `{"kind":"universe","action":"control_session"}`
+- Access: `{"kind":"universe","action":"configure_session"}`
- Group: `session`
-- Role: `contributor`
+- Role: `operator`
- Target: `sessionId`
- Params: `SessionEnvironmentDeactivateParams`
- Result: `AgentApiOutcome`
@@ -1309,7 +1309,7 @@ Terminal and idempotent: disables every trigger, drops schedules, and tells the
**Delete a bot**
-Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.
+Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.
- Access: `{"kind":"universe","action":"manage_bot"}`
- Group: `bots`
@@ -1587,6 +1587,32 @@ Queries the conversation workflow's live state for one chat, for debugging; abse
## Deployment methods
+### `deployment/sessions/audit/list`
+
+**Read session lifecycle audit**
+
+Returns durable lifecycle records, including deletion and purge evidence that survives session removal.
+
+- Access: `{"kind":"deployment"}`
+- Group: `deployment/sessions`
+- Role: `none`
+- Target: `none`
+- Params: `DeploymentSessionAuditListParams`
+- Result: `AgentApiOutcome`
+
+### `deployment/sessions/purge`
+
+**Permanently purge deleted sessions**
+
+Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.
+
+- Access: `{"kind":"deployment"}`
+- Group: `deployment/sessions`
+- Role: `none`
+- Target: `none`
+- Params: `DeploymentSessionPurgeParams`
+- Result: `AgentApiOutcome`
+
### `deployment/environment-provider-bindings/list`
**List a universe's deployment provider bindings**
diff --git a/crates/api/contract/api.schema.json b/crates/api/contract/api.schema.json
index ef95aaa89..b826c8b0f 100644
--- a/crates/api/contract/api.schema.json
+++ b/crates/api/contract/api.schema.json
@@ -1186,6 +1186,40 @@
],
"type": "object"
},
+ "AgentApiOutcomeOfDeploymentSessionAuditListResponse": {
+ "properties": {
+ "notifications": {
+ "items": {
+ "$ref": "#/definitions/AgentNotification"
+ },
+ "type": "array"
+ },
+ "result": {
+ "$ref": "#/definitions/DeploymentSessionAuditListResponse"
+ }
+ },
+ "required": [
+ "result"
+ ],
+ "type": "object"
+ },
+ "AgentApiOutcomeOfDeploymentSessionPurgeResponse": {
+ "properties": {
+ "notifications": {
+ "items": {
+ "$ref": "#/definitions/AgentNotification"
+ },
+ "type": "array"
+ },
+ "result": {
+ "$ref": "#/definitions/DeploymentSessionPurgeResponse"
+ }
+ },
+ "required": [
+ "result"
+ ],
+ "type": "object"
+ },
"AgentApiOutcomeOfDeploymentUniverseCreateResponse": {
"properties": {
"notifications": {
@@ -8856,6 +8890,72 @@
],
"type": "object"
},
+ "DeploymentSessionAuditListParams": {
+ "additionalProperties": false,
+ "properties": {
+ "limit": {
+ "description": "Most recent records, between 1 and 1000; defaults to 100.",
+ "format": "uint32",
+ "minimum": 0,
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "universeId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "DeploymentSessionAuditListResponse": {
+ "properties": {
+ "events": {
+ "items": {
+ "$ref": "#/definitions/SessionAuditEvent"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "events"
+ ],
+ "type": "object"
+ },
+ "DeploymentSessionPurgeParams": {
+ "additionalProperties": false,
+ "properties": {
+ "sessionId": {
+ "description": "An already deleted session; includes its deleted descendants.",
+ "type": "string"
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "universeId",
+ "sessionId"
+ ],
+ "type": "object"
+ },
+ "DeploymentSessionPurgeResponse": {
+ "properties": {
+ "purgedSessionIds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "purgedSessionIds"
+ ],
+ "type": "object"
+ },
"DeploymentUniverseCreateParams": {
"properties": {
"slug": {
@@ -11998,7 +12098,8 @@
"mcp",
"bots",
"deployment/universes",
- "deployment/api-keys"
+ "deployment/api-keys",
+ "deployment/sessions"
],
"type": "string"
},
@@ -12007,6 +12108,11 @@
"description": "`session/*`, `blobs/read` and `blobs/has`.",
"type": "string"
},
+ {
+ "const": "session/delete",
+ "description": "Destructive session operations and deletion retention.",
+ "type": "string"
+ },
{
"const": "blobs/put",
"description": "`blobs/put` alone, so connectors can upload attachments without\nreading sessions.",
@@ -13763,6 +13869,54 @@
}
]
},
+ "SessionAuditEvent": {
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "affectedSessionIds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "attribution": {
+ "$ref": "#/definitions/Attribution"
+ },
+ "cause": {
+ "type": "string"
+ },
+ "createdAtMs": {
+ "format": "uint64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "outcome": {
+ "type": "string"
+ },
+ "sessionId": {
+ "type": "string"
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "universeId",
+ "sessionId",
+ "action",
+ "attribution",
+ "cause",
+ "affectedSessionIds",
+ "createdAtMs",
+ "outcome"
+ ],
+ "type": "object"
+ },
"SessionCloseParams": {
"properties": {
"force": {
@@ -17679,7 +17833,9 @@
"read",
"create_session",
"control_session",
+ "configure_session",
"stop_session",
+ "close_session",
"delete_session",
"create_profile",
"manage_profile",
diff --git a/crates/api/contract/methods.json b/crates/api/contract/methods.json
index f97adef00..60ed7c217 100644
--- a/crates/api/contract/methods.json
+++ b/crates/api/contract/methods.json
@@ -151,7 +151,7 @@
},
{
"access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"description": "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op.",
@@ -169,7 +169,7 @@
},
"type": "AgentApiOutcome"
},
- "role": "contributor",
+ "role": "operator",
"scope": "universe",
"summary": "Replace session configuration",
"target": "sessionId"
@@ -201,7 +201,7 @@
},
{
"access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"description": "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched.",
@@ -219,18 +219,18 @@
},
"type": "AgentApiOutcome"
},
- "role": "contributor",
+ "role": "operator",
"scope": "universe",
"summary": "Replace session metadata",
"target": "sessionId"
},
{
"access": {
- "action": "control_session",
+ "action": "delete_session",
"kind": "universe"
},
"description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
- "group": "session",
+ "group": "session/delete",
"method": "session/retention/put",
"params": {
"schema": {
@@ -244,14 +244,14 @@
},
"type": "AgentApiOutcome"
},
- "role": "contributor",
+ "role": "admin",
"scope": "universe",
"summary": "Replace session retention",
"target": "sessionId"
},
{
"access": {
- "action": "stop_session",
+ "action": "close_session",
"kind": "universe"
},
"description": "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable.",
@@ -279,8 +279,8 @@
"action": "delete_session",
"kind": "universe"
},
- "description": "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.",
- "group": "session",
+ "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
+ "group": "session/delete",
"method": "session/delete",
"params": {
"schema": {
@@ -294,7 +294,7 @@
},
"type": "AgentApiOutcome"
},
- "role": "contributor",
+ "role": "admin",
"scope": "universe",
"summary": "Delete closed sessions",
"target": "sessionId"
@@ -626,7 +626,7 @@
},
{
"access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"description": "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes.",
@@ -644,14 +644,14 @@
},
"type": "AgentApiOutcome"
},
- "role": "contributor",
+ "role": "operator",
"scope": "universe",
"summary": "Apply a profile to a session",
"target": "sessionId"
},
{
"access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"description": "Selects an attached, live universe environment for environment-targeted tools while the session is idle.",
@@ -669,14 +669,14 @@
},
"type": "AgentApiOutcome"
},
- "role": "contributor",
+ "role": "operator",
"scope": "universe",
"summary": "Activate a session environment",
"target": "sessionId"
},
{
"access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"description": "Clears active environment selection without changing or closing the universe environment.",
@@ -694,7 +694,7 @@
},
"type": "AgentApiOutcome"
},
- "role": "contributor",
+ "role": "operator",
"scope": "universe",
"summary": "Deactivate the session environment",
"target": "sessionId"
@@ -2478,7 +2478,7 @@
"action": "manage_bot",
"kind": "universe"
},
- "description": "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.",
+ "description": "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.",
"group": "bots",
"method": "bots/delete",
"params": {
@@ -2997,6 +2997,54 @@
"summary": "Read a conversation snapshot",
"target": null
},
+ {
+ "access": {
+ "kind": "deployment"
+ },
+ "description": "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.",
+ "group": "deployment/sessions",
+ "method": "deployment/sessions/audit/list",
+ "params": {
+ "schema": {
+ "$ref": "#/definitions/DeploymentSessionAuditListParams"
+ },
+ "type": "DeploymentSessionAuditListParams"
+ },
+ "result": {
+ "schema": {
+ "$ref": "#/definitions/AgentApiOutcomeOfDeploymentSessionAuditListResponse"
+ },
+ "type": "AgentApiOutcome"
+ },
+ "role": null,
+ "scope": "deployment",
+ "summary": "Read session lifecycle audit",
+ "target": null
+ },
+ {
+ "access": {
+ "kind": "deployment"
+ },
+ "description": "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.",
+ "group": "deployment/sessions",
+ "method": "deployment/sessions/purge",
+ "params": {
+ "schema": {
+ "$ref": "#/definitions/DeploymentSessionPurgeParams"
+ },
+ "type": "DeploymentSessionPurgeParams"
+ },
+ "result": {
+ "schema": {
+ "$ref": "#/definitions/AgentApiOutcomeOfDeploymentSessionPurgeResponse"
+ },
+ "type": "AgentApiOutcome"
+ },
+ "role": null,
+ "scope": "deployment",
+ "summary": "Permanently purge deleted sessions",
+ "target": null
+ },
{
"access": {
"kind": "deployment"
diff --git a/crates/api/contract/openrpc.json b/crates/api/contract/openrpc.json
index 5c68ae3a6..ff2e61c41 100644
--- a/crates/api/contract/openrpc.json
+++ b/crates/api/contract/openrpc.json
@@ -1186,6 +1186,40 @@
],
"type": "object"
},
+ "AgentApiOutcomeOfDeploymentSessionAuditListResponse": {
+ "properties": {
+ "notifications": {
+ "items": {
+ "$ref": "#/components/schemas/AgentNotification"
+ },
+ "type": "array"
+ },
+ "result": {
+ "$ref": "#/components/schemas/DeploymentSessionAuditListResponse"
+ }
+ },
+ "required": [
+ "result"
+ ],
+ "type": "object"
+ },
+ "AgentApiOutcomeOfDeploymentSessionPurgeResponse": {
+ "properties": {
+ "notifications": {
+ "items": {
+ "$ref": "#/components/schemas/AgentNotification"
+ },
+ "type": "array"
+ },
+ "result": {
+ "$ref": "#/components/schemas/DeploymentSessionPurgeResponse"
+ }
+ },
+ "required": [
+ "result"
+ ],
+ "type": "object"
+ },
"AgentApiOutcomeOfDeploymentUniverseCreateResponse": {
"properties": {
"notifications": {
@@ -8856,6 +8890,72 @@
],
"type": "object"
},
+ "DeploymentSessionAuditListParams": {
+ "additionalProperties": false,
+ "properties": {
+ "limit": {
+ "description": "Most recent records, between 1 and 1000; defaults to 100.",
+ "format": "uint32",
+ "minimum": 0,
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "universeId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
+ "DeploymentSessionAuditListResponse": {
+ "properties": {
+ "events": {
+ "items": {
+ "$ref": "#/components/schemas/SessionAuditEvent"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "events"
+ ],
+ "type": "object"
+ },
+ "DeploymentSessionPurgeParams": {
+ "additionalProperties": false,
+ "properties": {
+ "sessionId": {
+ "description": "An already deleted session; includes its deleted descendants.",
+ "type": "string"
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "universeId",
+ "sessionId"
+ ],
+ "type": "object"
+ },
+ "DeploymentSessionPurgeResponse": {
+ "properties": {
+ "purgedSessionIds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "purgedSessionIds"
+ ],
+ "type": "object"
+ },
"DeploymentUniverseCreateParams": {
"properties": {
"slug": {
@@ -11998,7 +12098,8 @@
"mcp",
"bots",
"deployment/universes",
- "deployment/api-keys"
+ "deployment/api-keys",
+ "deployment/sessions"
],
"type": "string"
},
@@ -12007,6 +12108,11 @@
"description": "`session/*`, `blobs/read` and `blobs/has`.",
"type": "string"
},
+ {
+ "const": "session/delete",
+ "description": "Destructive session operations and deletion retention.",
+ "type": "string"
+ },
{
"const": "blobs/put",
"description": "`blobs/put` alone, so connectors can upload attachments without\nreading sessions.",
@@ -13763,6 +13869,54 @@
}
]
},
+ "SessionAuditEvent": {
+ "properties": {
+ "action": {
+ "type": "string"
+ },
+ "affectedSessionIds": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "attribution": {
+ "$ref": "#/components/schemas/Attribution"
+ },
+ "cause": {
+ "type": "string"
+ },
+ "createdAtMs": {
+ "format": "uint64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "id": {
+ "type": "string"
+ },
+ "outcome": {
+ "type": "string"
+ },
+ "sessionId": {
+ "type": "string"
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "universeId",
+ "sessionId",
+ "action",
+ "attribution",
+ "cause",
+ "affectedSessionIds",
+ "createdAtMs",
+ "outcome"
+ ],
+ "type": "object"
+ },
"SessionCloseParams": {
"properties": {
"force": {
@@ -17679,7 +17833,9 @@
"read",
"create_session",
"control_session",
+ "configure_session",
"stop_session",
+ "close_session",
"delete_session",
"create_profile",
"manage_profile",
@@ -18785,11 +18941,11 @@
},
"summary": "Replace session configuration",
"x-lightspeed-access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"x-lightspeed-group": "session",
- "x-lightspeed-role": "contributor",
+ "x-lightspeed-role": "operator",
"x-lightspeed-target": "sessionId"
},
{
@@ -18841,11 +18997,11 @@
},
"summary": "Replace session metadata",
"x-lightspeed-access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"x-lightspeed-group": "session",
- "x-lightspeed-role": "contributor",
+ "x-lightspeed-role": "operator",
"x-lightspeed-target": "sessionId"
},
{
@@ -18869,11 +19025,11 @@
},
"summary": "Replace session retention",
"x-lightspeed-access": {
- "action": "control_session",
+ "action": "delete_session",
"kind": "universe"
},
- "x-lightspeed-group": "session",
- "x-lightspeed-role": "contributor",
+ "x-lightspeed-group": "session/delete",
+ "x-lightspeed-role": "admin",
"x-lightspeed-target": "sessionId"
},
{
@@ -18897,7 +19053,7 @@
},
"summary": "Close a session",
"x-lightspeed-access": {
- "action": "stop_session",
+ "action": "close_session",
"kind": "universe"
},
"x-lightspeed-group": "session",
@@ -18905,7 +19061,7 @@
"x-lightspeed-target": "sessionId"
},
{
- "description": "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.",
+ "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
"name": "session/delete",
"paramStructure": "by-name",
"params": [
@@ -18928,8 +19084,8 @@
"action": "delete_session",
"kind": "universe"
},
- "x-lightspeed-group": "session",
- "x-lightspeed-role": "contributor",
+ "x-lightspeed-group": "session/delete",
+ "x-lightspeed-role": "admin",
"x-lightspeed-target": "sessionId"
},
{
@@ -19317,11 +19473,11 @@
},
"summary": "Apply a profile to a session",
"x-lightspeed-access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"x-lightspeed-group": "session",
- "x-lightspeed-role": "contributor",
+ "x-lightspeed-role": "operator",
"x-lightspeed-target": "sessionId"
},
{
@@ -19345,11 +19501,11 @@
},
"summary": "Activate a session environment",
"x-lightspeed-access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"x-lightspeed-group": "session",
- "x-lightspeed-role": "contributor",
+ "x-lightspeed-role": "operator",
"x-lightspeed-target": "sessionId"
},
{
@@ -19373,11 +19529,11 @@
},
"summary": "Deactivate the session environment",
"x-lightspeed-access": {
- "action": "control_session",
+ "action": "configure_session",
"kind": "universe"
},
"x-lightspeed-group": "session",
- "x-lightspeed-role": "contributor",
+ "x-lightspeed-role": "operator",
"x-lightspeed-target": "sessionId"
},
{
@@ -21368,7 +21524,7 @@
"x-lightspeed-target": null
},
{
- "description": "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.",
+ "description": "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.",
"name": "bots/delete",
"paramStructure": "by-name",
"params": [
@@ -21954,6 +22110,60 @@
"x-lightspeed-role": "viewer",
"x-lightspeed-target": null
},
+ {
+ "description": "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.",
+ "name": "deployment/sessions/audit/list",
+ "paramStructure": "by-name",
+ "params": [
+ {
+ "name": "params",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/DeploymentSessionAuditListParams"
+ }
+ }
+ ],
+ "result": {
+ "name": "result",
+ "schema": {
+ "$ref": "#/components/schemas/AgentApiOutcomeOfDeploymentSessionAuditListResponse"
+ }
+ },
+ "summary": "Read session lifecycle audit",
+ "x-lightspeed-access": {
+ "kind": "deployment"
+ },
+ "x-lightspeed-group": "deployment/sessions",
+ "x-lightspeed-role": null,
+ "x-lightspeed-target": null
+ },
+ {
+ "description": "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.",
+ "name": "deployment/sessions/purge",
+ "paramStructure": "by-name",
+ "params": [
+ {
+ "name": "params",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/DeploymentSessionPurgeParams"
+ }
+ }
+ ],
+ "result": {
+ "name": "result",
+ "schema": {
+ "$ref": "#/components/schemas/AgentApiOutcomeOfDeploymentSessionPurgeResponse"
+ }
+ },
+ "summary": "Permanently purge deleted sessions",
+ "x-lightspeed-access": {
+ "kind": "deployment"
+ },
+ "x-lightspeed-group": "deployment/sessions",
+ "x-lightspeed-role": null,
+ "x-lightspeed-target": null
+ },
{
"description": "Deployment configuration inventory of one universe's provider bindings.",
"name": "deployment/environment-provider-bindings/list",
diff --git a/crates/api/src/access.rs b/crates/api/src/access.rs
index cf05480cc..1c1b92bc7 100644
--- a/crates/api/src/access.rs
+++ b/crates/api/src/access.rs
@@ -47,6 +47,9 @@ pub enum MethodGroup {
/// `session/*`, `blobs/read` and `blobs/has`.
#[serde(rename = "session")]
Session,
+ /// Destructive session operations and deletion retention.
+ #[serde(rename = "session/delete")]
+ SessionDelete,
/// `blobs/put` alone, so connectors can upload attachments without
/// reading sessions.
#[serde(rename = "blobs/put")]
@@ -88,11 +91,14 @@ pub enum MethodGroup {
/// `deployment/channels/accounts/list`: a connector host's discovery.
#[serde(rename = "deployment/channels")]
DeploymentChannels,
+ #[serde(rename = "deployment/sessions")]
+ DeploymentSessions,
}
impl MethodGroup {
- pub const ALL: [MethodGroup; 17] = [
+ pub const ALL: [MethodGroup; 19] = [
Self::Session,
+ Self::SessionDelete,
Self::BlobsPut,
Self::Vfs,
Self::Profiles,
@@ -109,12 +115,14 @@ impl MethodGroup {
Self::DeploymentApiKeys,
Self::DeploymentEnvironmentProviders,
Self::DeploymentChannels,
+ Self::DeploymentSessions,
];
/// The stored and wire spelling.
pub fn as_str(self) -> &'static str {
match self {
Self::Session => "session",
+ Self::SessionDelete => "session/delete",
Self::BlobsPut => "blobs/put",
Self::Vfs => "vfs",
Self::Profiles => "profiles",
@@ -131,6 +139,7 @@ impl MethodGroup {
Self::DeploymentApiKeys => "deployment/api-keys",
Self::DeploymentEnvironmentProviders => "deployment/environment-providers",
Self::DeploymentChannels => "deployment/channels",
+ Self::DeploymentSessions => "deployment/sessions",
}
}
@@ -147,6 +156,7 @@ impl MethodGroup {
| Self::DeploymentApiKeys
| Self::DeploymentEnvironmentProviders
| Self::DeploymentChannels
+ | Self::DeploymentSessions
)
}
@@ -166,6 +176,10 @@ impl MethodGroup {
let group = |prefix: &str| method.starts_with(prefix);
Some(if method == "blobs/put" {
Self::BlobsPut
+ } else if matches!(method, "session/delete" | "session/retention/put") {
+ Self::SessionDelete
+ } else if group("deployment/sessions/") {
+ Self::DeploymentSessions
} else if group("session/") || group("blobs/") {
Self::Session
} else if group("transcriptions/") {
@@ -326,7 +340,9 @@ pub enum UniverseAction {
Read,
CreateSession,
ControlSession,
+ ConfigureSession,
StopSession,
+ CloseSession,
DeleteSession,
/// Share an unshared session with the universe.
ShareSession,
@@ -371,7 +387,7 @@ impl MethodAccess {
/// The least universe role a person should hold to call the method, for
/// gates built on this contract. `None` for machine and deployment
- /// methods. Ownership rules (a Contributor deleting only their own work)
+ /// methods. Ownership rules (a Contributor closing only their own unshared work)
/// are the gate's to add.
pub const fn recommended_role(self) -> Option {
use UniverseAction::*;
@@ -380,10 +396,11 @@ impl MethodAccess {
};
Some(match action {
Read => RecommendedRole::Viewer,
- CreateSession | ControlSession | StopSession | DeleteSession | ShareSession
+ DeleteSession => RecommendedRole::Admin,
+ CreateSession | ControlSession | StopSession | CloseSession | ShareSession
| InvokeBot | UseResource => RecommendedRole::Contributor,
- CreateProfile | ManageProfile | CreateBot | ManageBot | ConfigureResource
- | CreateWorkspace => RecommendedRole::Operator,
+ ConfigureSession | CreateProfile | ManageProfile | CreateBot | ManageBot
+ | ConfigureResource | CreateWorkspace => RecommendedRole::Operator,
})
}
}
@@ -526,6 +543,30 @@ mod tests {
);
}
+ #[test]
+ fn session_setup_requires_an_operator_but_ordinary_creation_and_runs_do_not() {
+ for method in [
+ "session/config/put",
+ "session/profiles/apply",
+ "session/metadata/put",
+ "session/environments/activate",
+ "session/environments/deactivate",
+ ] {
+ assert_eq!(
+ method_access(method).unwrap().recommended_role(),
+ Some(RecommendedRole::Operator),
+ "{method}",
+ );
+ }
+ for method in ["session/start", "session/runs/start", "session/rename"] {
+ assert_eq!(
+ method_access(method).unwrap().recommended_role(),
+ Some(RecommendedRole::Contributor),
+ "{method}",
+ );
+ }
+ }
+
#[test]
fn every_method_has_explicit_access_and_unknown_names_have_none() {
for spec in crate::schema_export::full_method_manifest() {
diff --git a/crates/api/src/rpc.rs b/crates/api/src/rpc.rs
index d3243eccb..1209a3744 100644
--- a/crates/api/src/rpc.rs
+++ b/crates/api/src/rpc.rs
@@ -345,17 +345,17 @@ api_methods! {
METHOD_SESSION_LIST => list_sessions(SessionListParams) -> SessionListResponse =>
["List sessions", "Returns a cursor-paginated summary list ordered by most recent update, optionally narrowed by the audience of each session's root: createdBy, visibility, or visibleTo (shared with the universe or created by that actor). Pages may shift while sessions are changing."], access: MethodAccess::Universe(UniverseAction::Read),
METHOD_SESSION_CONFIG_PUT => put_session_config(SessionConfigPutParams) -> SessionConfigPutResponse =>
- ["Replace session configuration", "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op."], access: MethodAccess::Universe(UniverseAction::ControlSession),
+ ["Replace session configuration", "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op."], access: MethodAccess::Universe(UniverseAction::ConfigureSession),
METHOD_SESSION_RENAME => rename_session(SessionRenameParams) -> SessionRenameResponse =>
["Rename a session", "Sets the display name, or clears it when displayName is omitted."], access: MethodAccess::Universe(UniverseAction::ControlSession),
METHOD_SESSION_METADATA_PUT => put_session_metadata(SessionMetadataPutParams) -> SessionMetadataPutResponse =>
- ["Replace session metadata", "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched."], access: MethodAccess::Universe(UniverseAction::ControlSession),
+ ["Replace session metadata", "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched."], access: MethodAccess::Universe(UniverseAction::ConfigureSession),
METHOD_SESSION_RETENTION_PUT => put_session_retention(SessionRetentionPutParams) -> SessionRetentionPutResponse =>
- ["Replace session retention", "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it."], access: MethodAccess::Universe(UniverseAction::ControlSession),
+ ["Replace session retention", "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
METHOD_SESSION_CLOSE => close_session(SessionCloseParams) -> SessionCloseResponse =>
- ["Close a session", "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable."], access: MethodAccess::Universe(UniverseAction::StopSession),
+ ["Close a session", "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable."], access: MethodAccess::Universe(UniverseAction::CloseSession),
METHOD_SESSION_DELETE => delete_session(SessionDeleteParams) -> SessionDeleteResponse =>
- ["Delete closed sessions", "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
+ ["Delete closed sessions", "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
METHOD_SESSION_SHARE => share_session(SessionShareParams) -> SessionShareResponse =>
["Share a session with the universe", "Moves an unshared root session to universe visibility, one way; its delegated children follow it. Refused on a bot's session, a delegated child, and a session already shared. Core applies it for any caller of the method; who may share is the caller's gate's decision."], access: MethodAccess::Universe(UniverseAction::ShareSession),
METHOD_SESSION_EVENTS_READ => read_session_events(SessionEventsReadParams) -> SessionEventsReadResponse =>
@@ -383,11 +383,11 @@ api_methods! {
METHOD_SESSION_SKILLS_LIST => list_skills(SkillListParams) -> SkillListResponse =>
["List available session skills", "Returns separate VFS and environment catalogs with source, reference, availability, readable skill paths, and warnings. Refreshes only when open with no active or queued run, without waking environments. Absent catalogs are omitted."], access: MethodAccess::Universe(UniverseAction::Read),
METHOD_SESSION_PROFILES_APPLY => apply_profile(ProfileApplyParams) -> ProfileApplyResponse =>
- ["Apply a profile to a session", "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes."], access: MethodAccess::Universe(UniverseAction::ControlSession),
+ ["Apply a profile to a session", "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes."], access: MethodAccess::Universe(UniverseAction::ConfigureSession),
METHOD_SESSION_ENVIRONMENTS_ACTIVATE => activate_session_environment(SessionEnvironmentActivateParams) -> SessionEnvironmentActivateResponse =>
- ["Activate a session environment", "Selects an attached, live universe environment for environment-targeted tools while the session is idle."], access: MethodAccess::Universe(UniverseAction::ControlSession),
+ ["Activate a session environment", "Selects an attached, live universe environment for environment-targeted tools while the session is idle."], access: MethodAccess::Universe(UniverseAction::ConfigureSession),
METHOD_SESSION_ENVIRONMENTS_DEACTIVATE => deactivate_session_environment(SessionEnvironmentDeactivateParams) -> SessionEnvironmentDeactivateResponse =>
- ["Deactivate the session environment", "Clears active environment selection without changing or closing the universe environment."], access: MethodAccess::Universe(UniverseAction::ControlSession),
+ ["Deactivate the session environment", "Clears active environment selection without changing or closing the universe environment."], access: MethodAccess::Universe(UniverseAction::ConfigureSession),
METHOD_ENVIRONMENTS_CREDENTIALS_BIND => bind_environment_credential(EnvironmentCredentialBindParams) -> EnvironmentCredentialBindResponse =>
["Bind a credential into an environment", "Maps an environment variable name to an existing grant/provider/direct-secret handle for a universe environment. Requires configuring the environment and configuring resources in the universe. The response exposes only the source handle, never secret material."], access: MethodAccess::Universe(UniverseAction::ConfigureResource),
METHOD_ENVIRONMENTS_CREDENTIALS_LIST => list_environment_credentials(EnvironmentCredentialListParams) -> EnvironmentCredentialListResponse =>
@@ -531,7 +531,7 @@ api_methods! {
METHOD_BOTS_CLOSE => close_bot(BotCloseParams) -> BotCloseResponse =>
["Close a bot", "Terminal and idempotent: disables every trigger, drops schedules, and tells the controller to archive pending events and force-close its sessions. Returns once signalled; follow bots/state/read for closing to closed."], access: MethodAccess::Universe(UniverseAction::ManageBot),
METHOD_BOTS_DELETE => delete_bot(BotDeleteParams) -> BotDeleteResponse =>
- ["Delete a bot", "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again."], access: MethodAccess::Universe(UniverseAction::ManageBot),
+ ["Delete a bot", "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again."], access: MethodAccess::Universe(UniverseAction::ManageBot),
METHOD_BOTS_STATE_READ => read_bot_state(BotStateReadParams) -> BotStateReadResponse =>
["Read bot controller state", "Queries the controller workflow for its live snapshot (sessions, buffers, active and recent deliveries, budget) and lists sub-agent descendants. The controller is absent until the bot's first event."], access: MethodAccess::Universe(UniverseAction::Read),
METHOD_BOTS_SESSIONS_ROTATE => rotate_bot_session(BotSessionRotateParams) -> BotSessionRotateResponse =>
diff --git a/crates/temporal-runtime/src/gateway/service/controller.rs b/crates/temporal-runtime/src/gateway/service/controller.rs
index 4939f4428..cf1ae5ca1 100644
--- a/crates/temporal-runtime/src/gateway/service/controller.rs
+++ b/crates/temporal-runtime/src/gateway/service/controller.rs
@@ -48,7 +48,8 @@ pub fn authorize_controller(
|| matches!((&context.actor, &target.parent), (ResourceRef::Session(actor), Some(parent)) if actor == parent);
match action {
Read | CreateSession | UseResource => true,
- ControlSession | StopSession | DeleteSession | ManageBot => controls,
+ ControlSession | ConfigureSession | StopSession | CloseSession | DeleteSession
+ | ManageBot => controls,
_ => false,
}
}
@@ -104,11 +105,23 @@ mod tests {
Visibility::Universe,
);
let helper = bot("helper");
- for action in [ControlSession, StopSession, DeleteSession, Read] {
+ for action in [
+ ControlSession,
+ ConfigureSession,
+ StopSession,
+ DeleteSession,
+ Read,
+ ] {
assert!(authorize_controller(&helper, action, Some(&own)));
}
assert!(authorize_controller(&helper, Read, Some(&other)));
- for action in [ControlSession, StopSession, DeleteSession, ShareSession] {
+ for action in [
+ ControlSession,
+ ConfigureSession,
+ StopSession,
+ DeleteSession,
+ ShareSession,
+ ] {
assert!(!authorize_controller(&helper, action, Some(&other)));
}
// A bot manages itself, never another bot.
diff --git a/docs/documentation/access-and-security/people-and-roles.md b/docs/documentation/access-and-security/people-and-roles.md
index 526532803..ec377e051 100644
--- a/docs/documentation/access-and-security/people-and-roles.md
+++ b/docs/documentation/access-and-security/people-and-roles.md
@@ -21,12 +21,25 @@ the universe's resources.
| Role | What the person can do |
| --- | --- |
| **Viewer** | Read visible sessions, bots, and universe resources. A session they previously created remains visible after a downgrade to Viewer. |
-| **Contributor** | Create and continue sessions, steer or stop work, decide tool approvals, invoke bots, and use resources. Create and update workspaces. |
-| **Operator** | Create and configure profiles, bots, environments, MCP servers, credentials, and channels; manage bot triggers and replay bot events. |
+| **Contributor** | Create sessions with universe defaults or an existing profile; continue sessions, steer or stop work, decide tool approvals, invoke bots, and update workspace contents. |
+| **Operator** | Customize session setup and run options; create workspaces and create or configure profiles, bots, environments, MCP servers, credentials, and channels; manage bot triggers and replay bot events. |
| **Admin** | Manage universe members, settings, and API keys. Read, control, share, and delete every session, including private sessions. |
-Contributors and Operators can control any shared session. Sharing and deleting
-a session require its creator, with at least the Contributor role, or an Admin.
+Contributors can name a new session and choose its saved profile, but cannot
+author inline setup, override the chosen setup, create managed sessions, or
+create or edit profiles. After creation, changing configuration, custom
+instructions, metadata, or the active environment requires an Operator or
+Admin. Per-message model and reasoning overrides also require an Operator or
+Admin. These checks apply to the server, including a Contributor’s own sessions.
+
+Viewers and Contributors can inspect profiles in **Form** or **JSON** view and
+open **Session settings** for sessions they can read. The form keeps sections
+expandable and values readable, with editing controls protected and save
+actions hidden.
+
+Contributors and Operators can start and control runs in any shared session.
+Sharing and deleting a session require its creator, with at least the
+Contributor role, or an Admin.
The [private and shared work guide](private-and-shared-work.md) develops those
rules with an example.
diff --git a/docs/documentation/access-and-security/private-and-shared-work.md b/docs/documentation/access-and-security/private-and-shared-work.md
index 4c4bfa10a..801264bdb 100644
--- a/docs/documentation/access-and-security/private-and-shared-work.md
+++ b/docs/documentation/access-and-security/private-and-shared-work.md
@@ -36,8 +36,9 @@ flowchart LR
Sharing changes who may use the same continuing conversation. It does not make
a snapshot or start another session. Viewers can inspect it; Contributors and
-above can continue, steer, cancel, or configure its work, subject to ordinary
-session lifecycle rules. Someone joining the universe later can read its
+above can continue, steer, or cancel its work, subject to ordinary session
+lifecycle rules. Configuring the session or overriding a run's model or
+reasoning options requires an Operator or Admin. Someone joining the universe later can read its
shared history too.
## What each person may do
@@ -48,7 +49,8 @@ creator. These checks apply on the server as well as in the UI.
| Operation | Private session | Shared session |
| --- | --- | --- |
| Read | Creator and Admins | Every member |
-| Start, steer, cancel, approve tools, configure, or close | Creator with Contributor or Operator role, and Admins | Contributors, Operators, and Admins |
+| Start runs, steer, cancel, approve tools, or close | Creator with Contributor or Operator role, and Admins | Contributors, Operators, and Admins |
+| Configure setup or override run options | Creator with Operator role, and Admins | Operators and Admins |
| Share | Creator with Contributor or Operator role, and Admins | Already shared; no reverse operation |
| Delete | Creator with Contributor or Operator role, and Admins | Creator with Contributor or Operator role, and Admins |
diff --git a/docs/documentation/using-lightspeed/profiles-and-instructions.md b/docs/documentation/using-lightspeed/profiles-and-instructions.md
index 818369e25..99c3b827e 100644
--- a/docs/documentation/using-lightspeed/profiles-and-instructions.md
+++ b/docs/documentation/using-lightspeed/profiles-and-instructions.md
@@ -9,7 +9,14 @@ model that can call tools, and write access to the release workspace. A
release reviewer can use the same files with read-only access and a different
job. Separate profiles let you reuse each setup without configuring it again.
-Profiles belong to a universe. Use an Operator or Admin account to manage them.
+Profiles belong to a universe. Use an Operator or Admin account to create or
+edit them. Contributors can select an existing profile when creating a session,
+but cannot customize its setup or save a new profile.
+
+Viewers and Contributors can inspect the same **Form** view used by editors,
+including expandable configuration sections and readable input values. Editing
+controls are read-only or disabled, and save actions are hidden. **JSON** remains
+available as a read-only view of the underlying document.
## Create a profile for a job
@@ -126,9 +133,11 @@ A new ordinary session receives the profile's setup at creation. Saving a
later profile revision affects future sessions; existing conversations keep
their current setup until you apply a change.
-For a one-off change, open the idle session's **Session settings**, edit the
-setup, and choose **Apply setup**. To apply a saved profile to an existing
-ordinary session, use the CLI with the connection settings described in
+For a one-off change in the Platform, an Operator or Admin opens the idle
+session's **Session settings**, edits the setup, and chooses **Apply setup**.
+Contributors can inspect these settings but cannot change them, including
+custom instructions or the active environment. To apply a saved profile to an
+existing ordinary session, use the CLI with the connection settings described in
[Sessions and runs](sessions-and-runs.md#continue-from-the-cli):
```bash
@@ -163,10 +172,11 @@ successor. See [Bots and triggers](bots-and-triggers.md).
## Set limits and a default environment
The advanced **Run limits** fields include **Max turns** and **Max tool
-rounds**. They bound a run's work under the selected defaults. API callers can
-provide per-run overrides, so these fields should not be treated as hard
-authorization ceilings. Bot daily budgets and sub-agent tree limits govern
-different scopes.
+rounds**. They bound a run's work under the selected defaults. Direct runtime
+API callers with suitable key access can provide per-run overrides, so these
+fields should not be treated as hard authorization ceilings. The Platform
+refuses Contributor requests with run configuration overrides. Bot daily budgets
+and sub-agent tree limits govern different scopes.
Choose a default environment attachment when new sessions should start with
an active machine. Without one, the session starts with no active environment.
diff --git a/docs/documentation/using-lightspeed/sessions-and-runs.md b/docs/documentation/using-lightspeed/sessions-and-runs.md
index 63e73818e..e8c224b6f 100644
--- a/docs/documentation/using-lightspeed/sessions-and-runs.md
+++ b/docs/documentation/using-lightspeed/sessions-and-runs.md
@@ -12,16 +12,21 @@ retains both the work and its history when you leave the page.
Use a Contributor, Operator or Admin account in the universe for the web
procedures below. To control an existing private session, you must be its
creator or an Admin; shared sessions can be controlled by Contributors and
-above. If you haven't completed a task yet, start with
+above. Changing session setup or per-message model and reasoning options
+requires an Operator or Admin. If you haven't completed a task yet, start with
[Build your first agent](../getting-started/first-agent.md).
## Start and continue a session
-Open **Sessions → New session**, enter a **Name**, and select a **Profile**.
-Choose **Create** to use the saved profile. **Customize setup…** lets you
-change the setup for this session without saving those changes back to the
-profile. After customizing, choose **Create session**. You can also start
-without a profile and configure the session directly.
+Open **Sessions → New session**, enter a **Name**, and select an existing
+**Profile** or **No profile (universe default)**. Choose **Create**. The profile
+is resolved at creation; later profile edits do not change this session.
+Contributors use these prepared choices without configuration overrides.
+
+Operators and Admins can choose **Customize setup…** to change the setup for
+this session without saving those changes back to the profile. After
+customizing, choose **Create session**. They can also customize a session
+that starts without a profile.
Send a task in the composer. For the release editor from the first-agent
walkthrough, try:
@@ -140,8 +145,8 @@ window. Recovery resumes the same run with completed tool results retained.
It can still fail when the protected input is too large or its bounded
attempts cannot make enough room.
-In the profile or idle session's model setup, open **Customize run controls →
-Context compaction**. **Engine default** and **Engine managed standalone**
+As an Operator or Admin, open **Customize run controls → Context compaction**
+in the profile or idle session's model setup. **Engine default** and **Engine managed standalone**
enable standalone compaction; **Provider triggered** uses supported OpenAI
Responses or Anthropic Messages generation compaction. **Disabled** turns off
automatic compaction and recovery. **Input limit tokens** overrides usable
@@ -190,9 +195,15 @@ those conversations. **Metadata filters** accept `key=value` pairs, and
Metadata is a descriptive map, for example `project=acorn` and
`purpose=release-review`. It does not grant access or instruct the model.
-Open **Session settings** to edit it, custom instructions, model configuration,
-and other setup, then choose **Apply setup**. Changes to the agent's working
-setup require an open session with no active or queued runs.
+Open **Session settings** to inspect metadata, custom instructions, model
+configuration, and attached resources. Viewers and Contributors see a read-only
+form: sections still expand, but values cannot be changed and **Apply setup**
+is hidden.
+
+Operators and Admins can edit the setup and choose **Apply setup**. Changes to
+the agent's working setup require an open session with no active or queued
+runs. The composer's per-message model and reasoning choices, including saving
+a model choice as the session default, also require an Operator or Admin.
A session keeps its configured provider identity and API kind for its entire
lifetime, including before its first run. You can switch model names within
@@ -317,6 +328,6 @@ the [bot conversation](bots-and-triggers.md) or connected chat for normal work.
| A message is waiting while the agent works | It may be a queued run. Use steering for an instruction intended for the current task. |
| Steering has no immediate visible effect | The current model call or tool batch must finish before the next model turn can consume it. |
| Work starts again after stopping | Check for other queued runs. Stopping one run leaves those tasks in place. |
-| Setup changes are refused | Wait for active work to finish and drain or cancel queued runs. Reload settings if another editor changed them. |
+| Setup changes are refused | Confirm you have an Operator or Admin role. Wait for active work to finish and drain or cancel queued runs. Reload settings if another editor changed them. |
| A finished child or closed conversation is missing | Under **Filter sessions → Include**, select **Closed sessions** and **Sub-agent sessions**, or follow the child link from the parent transcript. |
| The agent lost a detail from much earlier | Inspect the retained history and restate the needed fact or point it to the source file. The current model context can be smaller than the transcript. |
diff --git a/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md b/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md
index bdb1cd943..32785b610 100644
--- a/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md
+++ b/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md
@@ -102,12 +102,15 @@ has nothing to check on `blobs/read` beyond the method's role.
| Role | May |
| --- | --- |
| viewer | read shared work, lists, files, models |
-| contributor | viewer, plus start and control sessions and runs, create workspaces, invoke bots, approve tool calls in sessions they control, share their own sessions |
-| operator | contributor, plus create and configure profiles, bots, environments, MCP servers, credentials, channels |
+| contributor | viewer, plus create sessions from defaults or existing profiles, start and control runs, update workspace contents, invoke bots, approve tool calls in sessions they control, share their own sessions |
+| operator | contributor, plus customize session setup and run options, create workspaces, and create and configure profiles, bots, environments, MCP servers, credentials, channels |
| admin | operator, plus members and roles, delete any session, share any session, read unshared work |
The exact per-method table is the generated file; this table is what the
-manifest's `role` metadata must reproduce.
+manifest's `role` metadata must reproduce. The member client also checks
+creation and run payloads: contributor session creation permits only defaults
+or an existing named profile, and run requests cannot carry configuration
+overrides. Managed-session creation requires an operator.
### 5. Unshared work in the product
@@ -248,6 +251,27 @@ Notes on steps 1 and 2 as built:
shows what each key may call. The Configurator installer lets an admin keep
its key, mint one with chosen groups, or bring an existing key.
+## Contributor setup restrictions — implemented 2026-10-06
+
+- [x] Separate `ConfigureSession` from ordinary session control in the method
+ manifest. Config replacement, profile application, metadata, and environment
+ selection require an operator. Internal controller authority is preserved.
+- [x] Enforce prepared contributor creation in the member client, including
+ refusal of inline setup, creation overrides, managed sessions, and per-run
+ configuration overrides before forwarding to core. Profile writes remain
+ operator-only.
+- [x] Limit the contributor creation UI to universe defaults or an existing
+ profile. Remove contributor model/reasoning overrides and saved-default
+ controls from the composer.
+- [x] Reuse the config form for read-only profile and session inspection. Keep
+ disclosures usable, protect editing controls and change callbacks, and hide
+ save actions. Profile JSON remains readable.
+- [x] Regenerate the API contract and TypeScript consumers. Focused gate, form,
+ API access, controller, and contract-freshness tests pass; TypeScript checks
+ pass. Broader tests encountered concurrent session-deletion changes.
+- [x] Update the role, sharing, session, profile, and Platform documentation
+ with user approval.
+
## Validation
- Unit (Platform): every manifest method has a role entry; a viewer is
diff --git a/platform/README.md b/platform/README.md
index d50770f33..fb8f11639 100644
--- a/platform/README.md
+++ b/platform/README.md
@@ -133,8 +133,8 @@ four roles, least to most:
| Role | May |
| --- | --- |
| viewer | read shared work and their own private sessions |
-| contributor | also start and continue sessions and runs, invoke bots, share their own sessions |
-| operator | also configure profiles, bots, environments, MCP servers, credentials and channels |
+| contributor | also create sessions from defaults or an existing profile, continue runs, invoke bots, share their own sessions |
+| operator | also customize sessions and run options, and configure profiles, bots, environments, MCP servers, credentials and channels |
| admin | also manage members and keys, and read, share and delete any session |
A universe's creator is its admin, and a universe always keeps one. A platform
@@ -151,6 +151,9 @@ a route makes for a member goes through one client
`backend/src/routes/method-roles.ts`. That table is generated from the core
method manifest by `node platform/scripts/generate-method-roles.mjs`, and
`npm run check` fails when it is stale;
+- limits contributor creation to ordinary sessions with universe defaults or a
+ named profile, without setup overrides; managed creation and run configuration
+ overrides require an operator;
- for a method that names a session, unless the member is an admin, requires the
session to be shared with the universe or created by the member; sharing and
deleting need its creator;
@@ -161,6 +164,12 @@ a route makes for a member goes through one client
The Platform's own refusals are 403 and 404. Core refusing the Platform is a
server fault (500 or 502), since the member was already admitted. The web's
permission hints come from the same role and never replace these checks.
+`configure_session` is separate from `control_session`: configuration, profile
+application, metadata, and active-environment changes require an operator,
+while ordinary run controls remain available to contributors. Profile creation
+and editing also require an operator. Readers use the same expandable
+configuration form in profiles and session settings, with protected controls
+and no save action; profile JSON remains available for inspection.
**Private work.** Sessions start private: their creator and the universe's
admins see them. **Share with universe…** in the session's ⋯ menu shares a
diff --git a/platform/backend/src/routes/messages.test.ts b/platform/backend/src/routes/messages.test.ts
index 4bd084c1a..b3a432652 100644
--- a/platform/backend/src/routes/messages.test.ts
+++ b/platform/backend/src/routes/messages.test.ts
@@ -41,7 +41,8 @@ function fixture() {
return { call, requests, fetch };
}
-it("sends attachments as media items before the text, with per-message run options", async () => {
+it("sends operator attachments as media items before the text, with per-message run options", async () => {
+ auth.role = "operator";
const f = fixture();
const response = await f.call("/sessions/s1/messages", {
text: "Compare these", submissionId: "sub", attachments: [image, pdf],
@@ -104,3 +105,12 @@ it("uploads attachments as blobs for contributors only", async () => {
expect((await viewer.call("/attachments", { bytesBase64: btoa("png") })).status).toBe(403);
expect(viewer.fetch).not.toHaveBeenCalled();
});
+
+
+it("rejects contributor model and reasoning overrides before reaching the runtime", async () => {
+ const f = fixture();
+ for (const options of [{ model: route }, { reasoningEffort: "high" }]) {
+ expect((await f.call("/sessions/s1/messages", { text: "Hello", submissionId: "sub", options })).status).toBe(403);
+ }
+ expect(f.fetch).not.toHaveBeenCalled();
+});
diff --git a/platform/backend/src/routes/method-roles.ts b/platform/backend/src/routes/method-roles.ts
index a619a3d9d..d1f27e2c9 100644
--- a/platform/backend/src/routes/method-roles.ts
+++ b/platform/backend/src/routes/method-roles.ts
@@ -87,22 +87,22 @@ export const METHOD_ROLES: Readonly> = {
"profiles/put": "operator",
"profiles/read": "viewer",
"session/close": "contributor",
- "session/config/put": "contributor",
+ "session/config/put": "operator",
"session/context/append": "contributor",
"session/context/compact": "contributor",
"session/context/remove": "contributor",
"session/context/replace": "contributor",
- "session/delete": "contributor",
- "session/environments/activate": "contributor",
- "session/environments/deactivate": "contributor",
+ "session/delete": "admin",
+ "session/environments/activate": "operator",
+ "session/environments/deactivate": "operator",
"session/events/read": "viewer",
"session/list": "viewer",
"session/managed/start": "contributor",
- "session/metadata/put": "contributor",
- "session/profiles/apply": "contributor",
+ "session/metadata/put": "operator",
+ "session/profiles/apply": "operator",
"session/read": "viewer",
"session/rename": "contributor",
- "session/retention/put": "contributor",
+ "session/retention/put": "admin",
"session/runs/approvals/decide": "contributor",
"session/runs/cancel": "contributor",
"session/runs/list": "viewer",
@@ -171,6 +171,8 @@ export const UNMEMBERED_METHODS: ReadonlySet = new Set([
"deployment/environment-providers/put",
"deployment/environment-providers/read",
"deployment/environments/adopt",
+ "deployment/sessions/audit/list",
+ "deployment/sessions/purge",
"deployment/universes/create",
"deployment/universes/delete",
"deployment/universes/list",
diff --git a/platform/backend/src/runtime-client.test.ts b/platform/backend/src/runtime-client.test.ts
index 4b76bf544..35687813e 100644
--- a/platform/backend/src/runtime-client.test.ts
+++ b/platform/backend/src/runtime-client.test.ts
@@ -98,9 +98,9 @@ describe("session targets", () => {
expect(calls.map((call) => call.method)).toEqual(["session/events/read"]);
});
- it("keep share and delete with the creator even on shared work", async () => {
+ it("keep sharing with the creator even on shared work", async () => {
core({ team: { visibility: "universe", createdBy: { kind: "actor", id: "bob" } } });
- for (const method of ["session/share", "session/delete"] as const) {
+ for (const method of ["session/share"] as const) {
const error = await refusal(as("contributor").call(method, { sessionId: "team" } as never));
expect((error as GateRefusal).status).toBe(404);
}
@@ -145,3 +145,101 @@ describe("lists and transport", () => {
expect(() => deploymentClient({ ...env, lightspeedApiKey: null })).toThrow("runtime endpoint");
});
});
+
+
+describe("prepared contributor sessions", () => {
+ it.each([
+ {},
+ { displayName: "Research", profile: { kind: "inline", profile: {} } },
+ { profile: { kind: "named", profileId: "approved" } },
+ ])("allows defaults and existing profiles: %j", async (params) => {
+ const calls = core();
+ await as("contributor").call("session/start", params as never);
+ expect(calls.map((call) => call.method)).toEqual(["session/start"]);
+ expect(calls[0]!.params).toMatchObject(params);
+ });
+
+ it.each([
+ { config: {} },
+ { config: { model: { model: "custom" } } },
+ { metadata: { team: "custom" } },
+ { deleteAfterCloseMs: null },
+ { deleteAfterCloseMs: 1000 },
+ { access: { visibility: "universe" } },
+ { profile: { kind: "inline", profile: { instructions: { type: "text", text: "Override" } } } },
+ { profile: { kind: "inline", profile: { config: {} } } },
+ { profile: { kind: "named", profileId: "approved", config: {} } },
+ { profile: { kind: "named", profileId: "approved" }, config: {} },
+ ])("refuses contributor overrides before calling core: %j", async (params) => {
+ const calls = core();
+ const error = await refusal(as("contributor").call("session/start", params as never));
+ expect(error).toBeInstanceOf(GateRefusal);
+ expect((error as GateRefusal).status).toBe(403);
+ expect(calls).toHaveLength(0);
+ });
+
+ it.each([
+ "session/config/put", "session/profiles/apply", "session/metadata/put",
+ "session/environments/activate", "session/environments/deactivate",
+ "session/managed/start", "profiles/create", "profiles/put", "profiles/delete",
+ ] as const)("keeps %s out of contributor access, including owned sessions", async (method) => {
+ const calls = core({ own: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } });
+ const error = await refusal(as("contributor").call(method, { sessionId: "own" } as never));
+ expect(error).toBeInstanceOf(GateRefusal);
+ expect((error as GateRefusal).status).toBe(403);
+ expect(calls).toHaveLength(0);
+ });
+
+ it("allows contributor runs but refuses per-run configuration overrides", async () => {
+ const calls = core({ own: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } });
+ const params = { sessionId: "own", source: { type: "input", items: [] } } as const;
+ const error = await refusal(as("contributor").call("session/runs/start", { ...params, config: {} } as never));
+ expect((error as GateRefusal).status).toBe(403);
+ expect(calls).toHaveLength(0);
+ await as("contributor").call("session/runs/start", params as never);
+ expect(calls.map((call) => call.method)).toEqual(["session/read", "session/runs/start"]);
+ });
+
+ it.each(["operator", "admin"] as const)("preserves custom creation and configuration for %s", async (role) => {
+ const calls = core({ own: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } });
+ await as(role).call("session/start", { profile: { kind: "inline", profile: { config: {} } } } as never);
+ await as(role).call("session/config/put", { sessionId: "own", config: {} } as never);
+ expect(calls.map((call) => call.method)).toContain("session/config/put");
+ });
+});
+
+
+describe("session lifecycle permissions", () => {
+ for (const role of ["viewer", "contributor", "operator", "admin"] as const) {
+ for (const creator of [true, false]) for (const shared of [true, false]) {
+ it(`${role} closes creator=${creator} shared=${shared} only when permitted`, async () => {
+ core({ s: { visibility: shared ? "universe" : "restricted", createdBy: { kind: "actor", id: creator ? "alice" : "bob" } } });
+ const allowed = role === "admin" || (role === "operator" && (creator || shared)) || (role === "contributor" && creator && !shared);
+ for (const force of [false, true]) {
+ const error = await refusal(as(role).call("session/close", { sessionId: "s", force }));
+ expect(error === null).toBe(allowed);
+ }
+ });
+ }
+ it(`${role} deletes and configures retention only as admin`, async () => {
+ const calls = core({ s: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } });
+ for (const method of ["session/delete", "session/retention/put"] as const) {
+ const error = await refusal(as(role).call(method, { sessionId: "s", deleteAfterCloseMs: 1 } as never));
+ expect(error === null).toBe(role === "admin");
+ }
+ if (role !== "admin") expect(calls).toHaveLength(0);
+ });
+ }
+ it("contributors still cancel shared runs", async () => {
+ core({ s: { visibility: "universe" } });
+ await expect(as("contributor").call("session/runs/cancel", { sessionId: "s", runId: "r" })).resolves.toBeDefined();
+ });
+ it("non-admin starts override profile retention and reject an explicit deletion schedule", async () => {
+ const calls = core();
+ for (const method of ["session/start", "session/managed/start"] as const) {
+ await as("operator").call(method, { profile: { kind: "named", profileId: "scheduled" } } as never);
+ expect(calls.at(-1)!.params.deleteAfterCloseMs).toBeNull();
+ expect(await refusal(as("operator").call(method, { deleteAfterCloseMs: 1 } as never))).toBeInstanceOf(GateRefusal);
+ }
+ });
+});
diff --git a/platform/backend/src/runtime-client.ts b/platform/backend/src/runtime-client.ts
index 135b22515..cf1a88508 100644
--- a/platform/backend/src/runtime-client.ts
+++ b/platform/backend/src/runtime-client.ts
@@ -6,7 +6,7 @@ import {
type MethodParams,
type MethodResult,
} from "@lightspeed-ai/sdk";
-import { roleAtLeast, type UniverseRole } from "@lightspeed-ai/platform-shared";
+import { canCloseSession, roleAtLeast, type UniverseRole } from "@lightspeed-ai/platform-shared";
import type { ServerEnv } from "./env.js";
import { METHOD_ROLES, SESSION_TARGET_METHODS } from "./routes/method-roles.js";
@@ -27,14 +27,14 @@ export interface Member {
}
/// Methods only a session's creator, or an admin, may call.
-const CREATOR_METHODS: ReadonlySet = new Set(["session/share", "session/delete"]);
+const CREATOR_METHODS: ReadonlySet = new Set(["session/share"]);
/// Methods that may name a session that does not exist yet.
const CREATION_METHODS: ReadonlySet = new Set(["session/start", "session/managed/start"]);
/// Deployment methods, with the Platform's deployment key and no universe or
/// actor. Callers check that the user is a platform admin.
-export function deploymentClient(env: ServerEnv, endpoint?: string | null): LightspeedClient {
- return new LightspeedClient(clientOptions(env, endpoint, {}));
+export function deploymentClient(env: ServerEnv, endpoint?: string | null, actorId?: string): LightspeedClient {
+ return new LightspeedClient(clientOptions(env, endpoint, actorId ? { "x-lightspeed-actor": actorId } : {}));
}
/// Calls as a universe key someone handed the Platform, to learn which key a
@@ -85,7 +85,22 @@ class MemberClient extends LightspeedClient {
const required = METHOD_ROLES[method];
if (!required) throw new GateRefusal(403, `${method} is not a member method`);
if (!roleAtLeast(this.member.role, required)) throw new GateRefusal(403, `${required} role required`);
+ if (!roleAtLeast(this.member.role, "operator")) {
+ if (method === "session/managed/start") {
+ throw new GateRefusal(403, "operator role required to create managed sessions");
+ }
+ if (method === "session/start") requirePreparedSession(params as MethodParams<"session/start">);
+ if (method === "session/runs/start" && (params as MethodParams<"session/runs/start">).config != null) {
+ throw new GateRefusal(403, "operator role required for run configuration overrides");
+ }
+ }
const admin = this.member.role === "admin";
+ if (!admin && CREATION_METHODS.has(method)) {
+ const start = params as { deleteAfterCloseMs?: number | null };
+ if (start.deleteAfterCloseMs != null) throw new GateRefusal(403, "admin role required for deletion retention");
+ // Explicit null also overrides profile-derived deletion schedules.
+ params = { ...params, deleteAfterCloseMs: null } as MethodParams;
+ }
if (!admin && SESSION_TARGET_METHODS.has(method)) {
await this.requireSession(method, (params as { sessionId?: string }).sessionId);
}
@@ -110,5 +125,25 @@ class MemberClient extends LightspeedClient {
const creator = access.createdBy?.kind === "actor" && access.createdBy.id === this.member.userId;
const visible = CREATOR_METHODS.has(method) ? creator : creator || access.visibility === "universe";
if (!visible) throw new GateRefusal(404, "session not found");
+ if (method === "session/close" && !canCloseSession(this.member.role, creator, access.visibility === "universe")) {
+ throw new GateRefusal(403, "contributors may close only their own unshared sessions");
+ }
+ }
+}
+
+/** Contributors choose a saved profile or the universe defaults, without overrides. */
+function requirePreparedSession(params: MethodParams<"session/start">): void {
+ const profile = params.profile;
+ const defaultProfile = profile == null || (profile.kind === "inline"
+ && profile.profile != null && Object.keys(profile.profile).length === 0
+ && Object.keys(profile).every((key) => key === "kind" || key === "profile"));
+ const namedProfile = profile?.kind === "named"
+ && Object.keys(profile).every((key) => key === "kind" || key === "profileId");
+ const allowedFields = new Set(["sessionId", "displayName", "profile", "access"]);
+ const overrides = Object.entries(params).some(([key, value]) =>
+ !allowedFields.has(key) && value !== undefined,
+ );
+ if ((!defaultProfile && !namedProfile) || overrides || params.access?.visibility === "universe") {
+ throw new GateRefusal(403, "contributors may create only default sessions or use an existing profile without overrides");
}
}
diff --git a/platform/configurator-mcp/src/generated/tools.ts b/platform/configurator-mcp/src/generated/tools.ts
index 76c7c1ce8..cf1842116 100644
--- a/platform/configurator-mcp/src/generated/tools.ts
+++ b/platform/configurator-mcp/src/generated/tools.ts
@@ -2174,7 +2174,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
{
"name": "lightspeed_session_retention_put",
"method": "session/retention/put",
- "group": "session",
+ "group": "session/delete",
"summary": "Replace session retention",
"description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
"paramsType": "SessionRetentionPutParams",
@@ -2234,9 +2234,9 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
{
"name": "lightspeed_session_delete",
"method": "session/delete",
- "group": "session",
+ "group": "session/delete",
"summary": "Delete closed sessions",
- "description": "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.",
+ "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
"paramsType": "SessionDeleteParams",
"resultType": "AgentApiOutcome",
"inputSchema": {
@@ -9997,7 +9997,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
"method": "bots/delete",
"group": "bots",
"summary": "Delete a bot",
- "description": "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.",
+ "description": "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.",
"paramsType": "BotDeleteParams",
"resultType": "AgentApiOutcome",
"inputSchema": {
diff --git a/platform/web/src/components/mcp/tool-picker.tsx b/platform/web/src/components/mcp/tool-picker.tsx
index d43bdd023..483102865 100644
--- a/platform/web/src/components/mcp/tool-picker.tsx
+++ b/platform/web/src/components/mcp/tool-picker.tsx
@@ -21,6 +21,7 @@ import {
} from "@/lib/mcp/tool-discovery";
type Props = {
+ readOnly?: boolean;
scope: "server" | "session";
serverId: string;
revision?: number;
@@ -45,15 +46,19 @@ export function McpToolPicker(props: Props) {
}
function ToolPicker({
+ readOnly = false,
scope,
serverId,
revision,
source,
allowedTools,
value,
- onChange,
+ onChange: onToolsChange,
discoveryDisabledReason,
}: Props) {
+ const onChange = (tools: string[] | undefined) => {
+ if (!readOnly) onToolsChange(tools);
+ };
const id = useId();
const limited = value !== undefined;
// A session subset starts closed like every optional setting; its summary
@@ -100,7 +105,7 @@ function ToolPicker({
const title = scope === "server" ? "Allowed tools" : "Tools";
const settings = (
<>
-
A new session is private: you and the universe admins see it until you share it.
@@ -1132,6 +1135,7 @@ function NewSessionDialog({
{modelSummary}
;
onValidityChange: (message: string | null) => void;
@@ -1236,7 +1242,7 @@ function InlineSetupEditor({
/>
)}
metadataDescription="Metadata copied onto the new session. It helps with filtering and does not affect how the session runs."
- retentionSetup={(
+ retentionSetup={canSetRetention && (
([]);
@@ -1347,9 +1352,12 @@ export function SessionDetail({
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteCascade, setDeleteCascade] = useState(false);
const permissions = useActionPermissions(universeId);
+ const mayClose = useSessionClosePermission(universeId);
const canControl = permissions.can("control_session");
+ const canConfigure = permissions.can("configure_session");
const canStop = permissions.can("stop_session");
- const canDelete = owner && permissions.can("delete_session");
+ const canClose = mayClose(session.data?.access);
+ const canDelete = permissions.can("delete_session");
const [settingsOpen, setSettingsOpen] = useState(false);
const [decidingApproval, setDecidingApproval] = useState<{
approvalId: string;
@@ -1671,7 +1679,7 @@ export function SessionDetail({
text,
submissionId,
...(message.attachments.length ? { attachments: message.attachments.map(wireAttachment) } : {}),
- ...(message.options ? { options: message.options } : {}),
+ ...(canConfigure && message.options ? { options: message.options } : {}),
},
);
setFollowRequest((request) => request + 1);
@@ -1722,6 +1730,7 @@ export function SessionDetail({
/// The composer's model choice becomes the session default. Config
/// replacement needs an idle session and the revision last read.
const saveModelDefault = async (config: Record) => {
+ if (!canConfigure) return;
const current = session.data;
if (!current) return;
const updated = await api(
@@ -1929,11 +1938,11 @@ export function SessionDetail({
sessionId={sessionId}
metadata={session.data?.metadata}
open={owningBotHref ? { label: "Open in bot", href: owningBotHref, icon: } : undefined}
- onSettings={canControl ? () => setSettingsOpen(true) : undefined}
+ onSettings={permissions.can("read") ? () => setSettingsOpen(true) : undefined}
onCompact={canControl && session.data && !closed ? compaction.compact : undefined}
compactionLabel={compaction.label}
onShare={canShare ? () => setShareOpen(true) : undefined}
- lifecycle={!managed && ((canStop && !closed) || (canDelete && closed)) ? (
+ lifecycle={(!managed || permissions.role === "admin") && ((canClose && !closed) || (canDelete && closed)) ? (
closed ? (
{
setCloseOpen(open);
if (open) setCloseError(null);
@@ -2024,9 +2033,9 @@ export function SessionDetail({
>
- Delete this session permanently?
+ Delete this session?
- This removes the session and its retained history. It cannot be undone.
+ This hides the session and its history from all universe members, including admins. Retained records are permanently purged after 30 days.
A session with history forks or delegated children cannot be deleted
unless cascade is enabled.
@@ -2040,7 +2049,7 @@ export function SessionDetail({
Also delete forks and delegated children
- Every descendant must already be closed and you must have permission to delete each one. Config-only clones are not included.
+ Every descendant must already be closed. Deleted sessions disappear for all universe members; retained records are purged after 30 days. Config-only clones are not included.
@@ -2052,7 +2061,7 @@ export function SessionDetail({
disabled={deleteSession.isPending}
onClick={() => deleteSession.mutate()}
>
- {deleteSession.isPending ? "Deleting…" : "Delete permanently"}
+ {deleteSession.isPending ? "Deleting…" : "Delete session"}
@@ -2068,7 +2077,7 @@ export function SessionDetail({
sessionHref={sessionHref}
resources={showSessionResources && session.data && hasResources(resources) ? (
setSettingsOpen(true) : undefined} />
+ onConfigure={!embedded && canConfigure ? () => setSettingsOpen(true) : undefined} />
) : undefined}
/>
@@ -2197,10 +2206,10 @@ export function SessionDetail({
) : undefined}
attachments={{ universeId, apiKind: modelFromConfig(session.data?.config)?.apiKind }}
- model={session.data?.config ? {
+ model={canConfigure && session.data?.config ? {
config: session.data.config,
models: modelDiscovery.data?.models,
- canSaveDefault: canControl && !closed,
+ canSaveDefault: canConfigure && !closed,
onSaveDefault: saveModelDefault,
} : undefined}
error={sendError}
@@ -2208,7 +2217,7 @@ export function SessionDetail({
onSend={(message, mode) => void send(message, mode)}
onStop={() => void stop()}
/>
- {!embedded && canControl && (
+ {!embedded && permissions.can("read") && (
Date: Tue, 6 Oct 2026 17:00:43 +0200
Subject: [PATCH 3/8] soft delete
---
clients/typescript/schema/api.schema.json | 188 ++++----
clients/typescript/src/generated/methods.ts | 46 +-
clients/typescript/src/generated/types.ts | 92 ++--
clients/typescript/test/client.test.ts | 2 +-
crates/api/contract/api-reference.md | 20 +-
crates/api/contract/api.schema.json | 188 ++++----
crates/api/contract/methods.json | 24 +-
crates/api/contract/openrpc.json | 208 ++++-----
crates/api/src/access.rs | 7 +-
crates/api/src/deployment.rs | 64 +++
crates/api/src/rpc.rs | 4 +-
crates/api/src/schema_export.rs | 7 +-
crates/api/src/sessions.rs | 4 +
crates/cli/src/session_cli.rs | 1 +
.../migrations/011_session_deletion.sql | 4 +
crates/store-pg/src/access.rs | 16 +-
crates/store-pg/src/deployment.rs | 4 +-
crates/store-pg/src/lib.rs | 1 +
crates/store-pg/src/migrations.rs | 7 +-
crates/store-pg/src/session.rs | 416 ++++++++++++------
.../store-pg/tests/session_lifecycle_live.rs | 220 +++++++++
crates/store-pg/tests/store_pg_live.rs | 17 +
crates/temporal-runtime/src/bots/admission.rs | 78 +++-
crates/temporal-runtime/src/bots/sessions.rs | 2 +-
.../src/gateway/deployment.rs | 66 +++
.../src/gateway/request_context.rs | 13 +
.../src/gateway/service/bots_api.rs | 18 +-
.../src/gateway/service/mod.rs | 1 +
.../src/gateway/service/session_lifecycle.rs | 16 +
.../temporal-runtime/src/session_deletion.rs | 9 +-
crates/temporal-runtime/src/worker/reaper.rs | 1 +
crates/temporal-runtime/tests/bots_live.rs | 42 +-
.../temporal-runtime/tests/profiles_live.rs | 1 +
.../temporal-runtime/tests/sessions_live.rs | 3 +
.../p190-session-deletion-and-closing.md | 105 +++++
platform/backend/src/api.ts | 2 +
platform/backend/src/auth.integration.test.ts | 1 +
platform/backend/src/routes/gateway.ts | 2 +-
platform/backend/src/routes/method-roles.ts | 4 +-
platform/backend/src/routes/session-admin.ts | 38 ++
.../src/routes/session-lifecycle.test.ts | 82 ++++
platform/backend/src/runtime-client.test.ts | 8 +-
platform/backend/src/runtime-client.ts | 12 +-
.../configurator-mcp/src/generated/tools.ts | 6 +-
platform/shared/src/index.ts | 11 +
platform/web/src/App.tsx | 2 +
.../admin/deleted-sessions.test.tsx | 53 +++
.../src/components/admin/deleted-sessions.tsx | 56 +++
.../web/src/components/app-shell.test.tsx | 14 +-
platform/web/src/components/app-shell.tsx | 2 +
platform/web/src/components/bot/setup.tsx | 8 +-
.../session/session-settings-sheet.tsx | 2 +-
platform/web/src/demo/router.test.ts | 13 +-
platform/web/src/demo/router.ts | 32 +-
platform/web/src/demo/routes/bots.ts | 3 -
platform/web/src/demo/routes/sessions.ts | 19 +-
platform/web/src/demo/store.ts | 2 +
platform/web/src/lib/method-groups.ts | 2 +
platform/web/src/lib/permissions.test.tsx | 3 +-
platform/web/src/lib/permissions.tsx | 10 +-
.../web/src/pages/AdminAuditPage.test.tsx | 55 +++
platform/web/src/pages/AdminAuditPage.tsx | 67 +++
platform/web/src/pages/AdminUniversesPage.tsx | 4 +
.../web/src/pages/AdminUsersPage.test.tsx | 1 +
platform/web/src/pages/AdminUsersPage.tsx | 33 --
.../pages/SessionsPage.permissions.test.tsx | 14 +-
platform/web/src/pages/SessionsPage.test.tsx | 2 +
platform/web/src/pages/SessionsPage.tsx | 20 +-
release/metadata.env | 2 +-
69 files changed, 1814 insertions(+), 666 deletions(-)
create mode 100644 crates/store-pg/migrations/011_session_deletion.sql
create mode 100644 docs/roadmap/p190-session-deletion-and-closing.md
create mode 100644 platform/backend/src/routes/session-admin.ts
create mode 100644 platform/backend/src/routes/session-lifecycle.test.ts
create mode 100644 platform/web/src/components/admin/deleted-sessions.test.tsx
create mode 100644 platform/web/src/components/admin/deleted-sessions.tsx
create mode 100644 platform/web/src/pages/AdminAuditPage.test.tsx
create mode 100644 platform/web/src/pages/AdminAuditPage.tsx
diff --git a/clients/typescript/schema/api.schema.json b/clients/typescript/schema/api.schema.json
index b826c8b0f..c2e65e0e5 100644
--- a/clients/typescript/schema/api.schema.json
+++ b/clients/typescript/schema/api.schema.json
@@ -1067,7 +1067,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentAdoptResponse": {
+ "AgentApiOutcomeOfDeploymentDeletedSessionsListResponse": {
"properties": {
"notifications": {
"items": {
@@ -1076,7 +1076,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentAdoptResponse"
+ "$ref": "#/definitions/DeploymentDeletedSessionsListResponse"
}
},
"required": [
@@ -1084,7 +1084,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderDeleteResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentAdoptResponse": {
"properties": {
"notifications": {
"items": {
@@ -1093,7 +1093,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderDeleteResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentAdoptResponse"
}
},
"required": [
@@ -1101,7 +1101,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderListResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderDeleteResponse": {
"properties": {
"notifications": {
"items": {
@@ -1110,7 +1110,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderListResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderDeleteResponse"
}
},
"required": [
@@ -1118,7 +1118,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderPutResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderListResponse": {
"properties": {
"notifications": {
"items": {
@@ -1127,7 +1127,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderPutResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderListResponse"
}
},
"required": [
@@ -1135,7 +1135,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderReadResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderPutResponse": {
"properties": {
"notifications": {
"items": {
@@ -1144,7 +1144,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderReadResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderPutResponse"
}
},
"required": [
@@ -1152,7 +1152,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentProviderBindingDeleteResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderReadResponse": {
"properties": {
"notifications": {
"items": {
@@ -1161,7 +1161,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentProviderBindingDeleteResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderReadResponse"
}
},
"required": [
@@ -1169,7 +1169,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentProviderBindingPutResponse": {
+ "AgentApiOutcomeOfDeploymentProviderBindingDeleteResponse": {
"properties": {
"notifications": {
"items": {
@@ -1178,7 +1178,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentProviderBindingPutResponse"
+ "$ref": "#/definitions/DeploymentProviderBindingDeleteResponse"
}
},
"required": [
@@ -1186,7 +1186,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentSessionAuditListResponse": {
+ "AgentApiOutcomeOfDeploymentProviderBindingPutResponse": {
"properties": {
"notifications": {
"items": {
@@ -1195,7 +1195,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentSessionAuditListResponse"
+ "$ref": "#/definitions/DeploymentProviderBindingPutResponse"
}
},
"required": [
@@ -8272,6 +8272,28 @@
],
"type": "object"
},
+ "DeletedSessionView": {
+ "properties": {
+ "deletedAtMs": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "displayName": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sessionId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sessionId",
+ "deletedAtMs"
+ ],
+ "type": "object"
+ },
"DeploymentApiKeyCreateParams": {
"additionalProperties": false,
"properties": {
@@ -8544,6 +8566,46 @@
],
"type": "object"
},
+ "DeploymentDeletedSessionsListParams": {
+ "additionalProperties": false,
+ "properties": {
+ "after": {
+ "default": null,
+ "description": "Exclusive session ID cursor; each page contains at most 100 entries.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "universeId"
+ ],
+ "type": "object"
+ },
+ "DeploymentDeletedSessionsListResponse": {
+ "properties": {
+ "nextAfter": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sessions": {
+ "items": {
+ "$ref": "#/definitions/DeletedSessionView"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "sessions"
+ ],
+ "type": "object"
+ },
"DeploymentEnvironmentAdoptParams": {
"properties": {
"bindingId": {
@@ -8890,46 +8952,10 @@
],
"type": "object"
},
- "DeploymentSessionAuditListParams": {
- "additionalProperties": false,
- "properties": {
- "limit": {
- "description": "Most recent records, between 1 and 1000; defaults to 100.",
- "format": "uint32",
- "minimum": 0,
- "type": [
- "integer",
- "null"
- ]
- },
- "universeId": {
- "type": [
- "string",
- "null"
- ]
- }
- },
- "type": "object"
- },
- "DeploymentSessionAuditListResponse": {
- "properties": {
- "events": {
- "items": {
- "$ref": "#/definitions/SessionAuditEvent"
- },
- "type": "array"
- }
- },
- "required": [
- "events"
- ],
- "type": "object"
- },
"DeploymentSessionPurgeParams": {
"additionalProperties": false,
"properties": {
"sessionId": {
- "description": "An already deleted session; includes its deleted descendants.",
"type": "string"
},
"universeId": {
@@ -8944,7 +8970,8 @@
},
"DeploymentSessionPurgeResponse": {
"properties": {
- "purgedSessionIds": {
+ "deletedSessionIds": {
+ "description": "Includes soft-deleted history forks and delegated descendants.\nEmpty when the target has already been permanently deleted.",
"items": {
"type": "string"
},
@@ -8952,7 +8979,7 @@
}
},
"required": [
- "purgedSessionIds"
+ "deletedSessionIds"
],
"type": "object"
},
@@ -13869,54 +13896,6 @@
}
]
},
- "SessionAuditEvent": {
- "properties": {
- "action": {
- "type": "string"
- },
- "affectedSessionIds": {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- "attribution": {
- "$ref": "#/definitions/Attribution"
- },
- "cause": {
- "type": "string"
- },
- "createdAtMs": {
- "format": "uint64",
- "minimum": 0,
- "type": "integer"
- },
- "id": {
- "type": "string"
- },
- "outcome": {
- "type": "string"
- },
- "sessionId": {
- "type": "string"
- },
- "universeId": {
- "type": "string"
- }
- },
- "required": [
- "id",
- "universeId",
- "sessionId",
- "action",
- "attribution",
- "cause",
- "affectedSessionIds",
- "createdAtMs",
- "outcome"
- ],
- "type": "object"
- },
"SessionCloseParams": {
"properties": {
"force": {
@@ -14046,6 +14025,10 @@
},
"sessionId": {
"type": "string"
+ },
+ "sharedOnly": {
+ "description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
+ "type": "boolean"
}
},
"required": [
@@ -17837,6 +17820,7 @@
"stop_session",
"close_session",
"delete_session",
+ "set_session_retention",
"create_profile",
"manage_profile",
"create_bot",
diff --git a/clients/typescript/src/generated/methods.ts b/clients/typescript/src/generated/methods.ts
index fa458b3b7..de8c942c0 100644
--- a/clients/typescript/src/generated/methods.ts
+++ b/clients/typescript/src/generated/methods.ts
@@ -125,7 +125,7 @@ export const METHODS = [
"channels/pairings/list",
"channels/pairings/delete",
"channels/conversations/read",
- "deployment/sessions/audit/list",
+ "deployment/sessions/deleted/list",
"deployment/sessions/purge",
"deployment/environment-provider-bindings/list",
"deployment/universes/create",
@@ -204,7 +204,7 @@ export const METHOD_INFO = {
},
"session/retention/put": {
scope: "universe",
- access: {"action":"delete_session","kind":"universe"},
+ access: {"action":"set_session_retention","kind":"universe"},
summary: "Replace session retention",
description: "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
},
@@ -218,7 +218,7 @@ export const METHOD_INFO = {
scope: "universe",
access: {"action":"delete_session","kind":"universe"},
summary: "Delete closed sessions",
- description: "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
+ description: "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
},
"session/share": {
scope: "universe",
@@ -868,17 +868,17 @@ export const METHOD_INFO = {
summary: "Read a conversation snapshot",
description: "Queries the conversation workflow's live state for one chat, for debugging; absent when no workflow exists yet.",
},
- "deployment/sessions/audit/list": {
+ "deployment/sessions/deleted/list": {
scope: "deployment",
access: {"kind":"deployment"},
- summary: "Read session lifecycle audit",
- description: "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.",
+ summary: "List deleted sessions",
+ description: "Administrative metadata for soft-deleted sessions, ordered by session ID. Unavailable through universe-scoped methods.",
},
"deployment/sessions/purge": {
scope: "deployment",
access: {"kind":"deployment"},
- summary: "Permanently purge deleted sessions",
- description: "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.",
+ summary: "Permanently delete a session",
+ description: "Removes a soft-deleted session and its soft-deleted history subtree. Rejects any selected session that has not been soft-deleted. Releases event, checkpoint and blob references; unreferenced blobs follow normal garbage collection. Does not delete external workspaces, environments, backups or Temporal history.",
},
"deployment/environment-provider-bindings/list": {
scope: "deployment",
@@ -1105,7 +1105,7 @@ export interface MethodMap {
/**
* Delete closed sessions
*
- * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.
+ * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.
*/
"session/delete": {
params: Api.SessionDeleteParams;
@@ -2084,18 +2084,18 @@ export interface MethodMap {
result: Api.AgentApiOutcomeOfChannelConversationReadResponse;
};
/**
- * Read session lifecycle audit
+ * List deleted sessions
*
- * Returns durable lifecycle records, including deletion and purge evidence that survives session removal.
+ * Administrative metadata for soft-deleted sessions, ordered by session ID. Unavailable through universe-scoped methods.
*/
- "deployment/sessions/audit/list": {
- params: Api.DeploymentSessionAuditListParams;
- result: Api.AgentApiOutcomeOfDeploymentSessionAuditListResponse;
+ "deployment/sessions/deleted/list": {
+ params: Api.DeploymentDeletedSessionsListParams;
+ result: Api.AgentApiOutcomeOfDeploymentDeletedSessionsListResponse;
};
/**
- * Permanently purge deleted sessions
+ * Permanently delete a session
*
- * Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.
+ * Removes a soft-deleted session and its soft-deleted history subtree. Rejects any selected session that has not been soft-deleted. Releases event, checkpoint and blob references; unreferenced blobs follow normal garbage collection. Does not delete external workspaces, environments, backups or Temporal history.
*/
"deployment/sessions/purge": {
params: Api.DeploymentSessionPurgeParams;
@@ -2364,7 +2364,7 @@ export const rpc = {
/**
* Delete closed sessions
*
- * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.
+ * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.
*/
sessionDelete(client: RpcCaller, params: Api.SessionDeleteParams): Promise {
return client.call("session/delete", params);
@@ -3234,17 +3234,17 @@ export const rpc = {
return client.call("channels/conversations/read", params);
},
/**
- * Read session lifecycle audit
+ * List deleted sessions
*
- * Returns durable lifecycle records, including deletion and purge evidence that survives session removal.
+ * Administrative metadata for soft-deleted sessions, ordered by session ID. Unavailable through universe-scoped methods.
*/
- deploymentSessionsAuditList(client: RpcCaller, params: Api.DeploymentSessionAuditListParams): Promise {
- return client.call("deployment/sessions/audit/list", params);
+ deploymentSessionsDeletedList(client: RpcCaller, params: Api.DeploymentDeletedSessionsListParams): Promise {
+ return client.call("deployment/sessions/deleted/list", params);
},
/**
- * Permanently purge deleted sessions
+ * Permanently delete a session
*
- * Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.
+ * Removes a soft-deleted session and its soft-deleted history subtree. Rejects any selected session that has not been soft-deleted. Releases event, checkpoint and blob references; unreferenced blobs follow normal garbage collection. Does not delete external workspaces, environments, backups or Temporal history.
*/
deploymentSessionsPurge(client: RpcCaller, params: Api.DeploymentSessionPurgeParams): Promise {
return client.call("deployment/sessions/purge", params);
diff --git a/clients/typescript/src/generated/types.ts b/clients/typescript/src/generated/types.ts
index 48e194552..7f6e30d04 100644
--- a/clients/typescript/src/generated/types.ts
+++ b/clients/typescript/src/generated/types.ts
@@ -1823,6 +1823,7 @@ export type UniverseAction =
| "stop_session"
| "close_session"
| "delete_session"
+ | "set_session_retention"
| "create_profile"
| "manage_profile"
| "create_bot"
@@ -4602,6 +4603,31 @@ export interface DeploymentChannelAccountView {
universeId: string;
updatedAtMs: number;
}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "AgentApiOutcomeOfDeploymentDeletedSessionsListResponse".
+ */
+export interface AgentApiOutcomeOfDeploymentDeletedSessionsListResponse {
+ notifications?: AgentNotification[];
+ result: DeploymentDeletedSessionsListResponse;
+}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "DeploymentDeletedSessionsListResponse".
+ */
+export interface DeploymentDeletedSessionsListResponse {
+ nextAfter?: string | null;
+ sessions: DeletedSessionView[];
+}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "DeletedSessionView".
+ */
+export interface DeletedSessionView {
+ deletedAtMs: number;
+ displayName?: string | null;
+ sessionId: string;
+}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
* via the `definition` "AgentApiOutcomeOfDeploymentEnvironmentAdoptResponse".
@@ -4812,36 +4838,6 @@ export interface AgentApiOutcomeOfDeploymentProviderBindingPutResponse {
export interface DeploymentProviderBindingPutResponse {
binding: EnvironmentProviderBindingView;
}
-/**
- * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
- * via the `definition` "AgentApiOutcomeOfDeploymentSessionAuditListResponse".
- */
-export interface AgentApiOutcomeOfDeploymentSessionAuditListResponse {
- notifications?: AgentNotification[];
- result: DeploymentSessionAuditListResponse;
-}
-/**
- * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
- * via the `definition` "DeploymentSessionAuditListResponse".
- */
-export interface DeploymentSessionAuditListResponse {
- events: SessionAuditEvent[];
-}
-/**
- * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
- * via the `definition` "SessionAuditEvent".
- */
-export interface SessionAuditEvent {
- action: string;
- affectedSessionIds: string[];
- attribution: Attribution;
- cause: string;
- createdAtMs: number;
- id: string;
- outcome: string;
- sessionId: string;
- universeId: string;
-}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
* via the `definition` "AgentApiOutcomeOfDeploymentSessionPurgeResponse".
@@ -4855,7 +4851,11 @@ export interface AgentApiOutcomeOfDeploymentSessionPurgeResponse {
* via the `definition` "DeploymentSessionPurgeResponse".
*/
export interface DeploymentSessionPurgeResponse {
- purgedSessionIds: string[];
+ /**
+ * Includes soft-deleted history forks and delegated descendants.
+ * Empty when the target has already been permanently deleted.
+ */
+ deletedSessionIds: string[];
}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
@@ -7302,6 +7302,17 @@ export interface DeploymentChannelAccountListParams {
includeDisabled?: boolean;
provider?: ChannelProvider | null;
}
+/**
+ * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
+ * via the `definition` "DeploymentDeletedSessionsListParams".
+ */
+export interface DeploymentDeletedSessionsListParams {
+ /**
+ * Exclusive session ID cursor; each page contains at most 100 entries.
+ */
+ after?: string | null;
+ universeId: string;
+}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
* via the `definition` "DeploymentEnvironmentAdoptParams".
@@ -7381,25 +7392,11 @@ export interface DeploymentProviderBindingPutParams {
status: EnvironmentProviderBindingStatusView;
universeId: string;
}
-/**
- * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
- * via the `definition` "DeploymentSessionAuditListParams".
- */
-export interface DeploymentSessionAuditListParams {
- /**
- * Most recent records, between 1 and 1000; defaults to 100.
- */
- limit?: number | null;
- universeId?: string | null;
-}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
* via the `definition` "DeploymentSessionPurgeParams".
*/
export interface DeploymentSessionPurgeParams {
- /**
- * An already deleted session; includes its deleted descendants.
- */
sessionId: string;
universeId: string;
}
@@ -8106,6 +8103,11 @@ export interface SessionDeleteParams {
*/
cascade?: boolean;
sessionId: string;
+ /**
+ * Atomically require every selected session's audience to be shared.
+ * Delegating services use this guard for operators deleting shared work.
+ */
+ sharedOnly?: boolean;
}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
diff --git a/clients/typescript/test/client.test.ts b/clients/typescript/test/client.test.ts
index 4b40f8277..5937f884a 100644
--- a/clients/typescript/test/client.test.ts
+++ b/clients/typescript/test/client.test.ts
@@ -43,7 +43,7 @@ describe("LightspeedClient", () => {
it("ships canonical method documentation from the Rust manifest", () => {
expect(METHOD_INFO["session/config/put"]).toEqual({
scope: "universe",
- access: { kind: "universe", action: "control_session" },
+ access: { kind: "universe", action: "configure_session" },
summary: "Replace session configuration",
description:
"Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op.",
diff --git a/crates/api/contract/api-reference.md b/crates/api/contract/api-reference.md
index e6855a091..edf141f93 100644
--- a/crates/api/contract/api-reference.md
+++ b/crates/api/contract/api-reference.md
@@ -135,7 +135,7 @@ Replaces the complete descriptive key/value map (bounded like session/start); an
Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.
-- Access: `{"kind":"universe","action":"delete_session"}`
+- Access: `{"kind":"universe","action":"set_session_retention"}`
- Group: `session/delete`
- Role: `admin`
- Target: `sessionId`
@@ -159,11 +159,11 @@ Closes an idle session and detaches its environment bindings. Force mode cancels
**Delete closed sessions**
-Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.
+Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.
- Access: `{"kind":"universe","action":"delete_session"}`
- Group: `session/delete`
-- Role: `admin`
+- Role: `operator`
- Target: `sessionId`
- Params: `SessionDeleteParams`
- Result: `AgentApiOutcome`
@@ -1587,24 +1587,24 @@ Queries the conversation workflow's live state for one chat, for debugging; abse
## Deployment methods
-### `deployment/sessions/audit/list`
+### `deployment/sessions/deleted/list`
-**Read session lifecycle audit**
+**List deleted sessions**
-Returns durable lifecycle records, including deletion and purge evidence that survives session removal.
+Administrative metadata for soft-deleted sessions, ordered by session ID. Unavailable through universe-scoped methods.
- Access: `{"kind":"deployment"}`
- Group: `deployment/sessions`
- Role: `none`
- Target: `none`
-- Params: `DeploymentSessionAuditListParams`
-- Result: `AgentApiOutcome`
+- Params: `DeploymentDeletedSessionsListParams`
+- Result: `AgentApiOutcome`
### `deployment/sessions/purge`
-**Permanently purge deleted sessions**
+**Permanently delete a session**
-Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.
+Removes a soft-deleted session and its soft-deleted history subtree. Rejects any selected session that has not been soft-deleted. Releases event, checkpoint and blob references; unreferenced blobs follow normal garbage collection. Does not delete external workspaces, environments, backups or Temporal history.
- Access: `{"kind":"deployment"}`
- Group: `deployment/sessions`
diff --git a/crates/api/contract/api.schema.json b/crates/api/contract/api.schema.json
index b826c8b0f..c2e65e0e5 100644
--- a/crates/api/contract/api.schema.json
+++ b/crates/api/contract/api.schema.json
@@ -1067,7 +1067,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentAdoptResponse": {
+ "AgentApiOutcomeOfDeploymentDeletedSessionsListResponse": {
"properties": {
"notifications": {
"items": {
@@ -1076,7 +1076,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentAdoptResponse"
+ "$ref": "#/definitions/DeploymentDeletedSessionsListResponse"
}
},
"required": [
@@ -1084,7 +1084,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderDeleteResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentAdoptResponse": {
"properties": {
"notifications": {
"items": {
@@ -1093,7 +1093,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderDeleteResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentAdoptResponse"
}
},
"required": [
@@ -1101,7 +1101,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderListResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderDeleteResponse": {
"properties": {
"notifications": {
"items": {
@@ -1110,7 +1110,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderListResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderDeleteResponse"
}
},
"required": [
@@ -1118,7 +1118,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderPutResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderListResponse": {
"properties": {
"notifications": {
"items": {
@@ -1127,7 +1127,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderPutResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderListResponse"
}
},
"required": [
@@ -1135,7 +1135,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderReadResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderPutResponse": {
"properties": {
"notifications": {
"items": {
@@ -1144,7 +1144,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentEnvironmentProviderReadResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderPutResponse"
}
},
"required": [
@@ -1152,7 +1152,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentProviderBindingDeleteResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderReadResponse": {
"properties": {
"notifications": {
"items": {
@@ -1161,7 +1161,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentProviderBindingDeleteResponse"
+ "$ref": "#/definitions/DeploymentEnvironmentProviderReadResponse"
}
},
"required": [
@@ -1169,7 +1169,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentProviderBindingPutResponse": {
+ "AgentApiOutcomeOfDeploymentProviderBindingDeleteResponse": {
"properties": {
"notifications": {
"items": {
@@ -1178,7 +1178,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentProviderBindingPutResponse"
+ "$ref": "#/definitions/DeploymentProviderBindingDeleteResponse"
}
},
"required": [
@@ -1186,7 +1186,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentSessionAuditListResponse": {
+ "AgentApiOutcomeOfDeploymentProviderBindingPutResponse": {
"properties": {
"notifications": {
"items": {
@@ -1195,7 +1195,7 @@
"type": "array"
},
"result": {
- "$ref": "#/definitions/DeploymentSessionAuditListResponse"
+ "$ref": "#/definitions/DeploymentProviderBindingPutResponse"
}
},
"required": [
@@ -8272,6 +8272,28 @@
],
"type": "object"
},
+ "DeletedSessionView": {
+ "properties": {
+ "deletedAtMs": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "displayName": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sessionId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sessionId",
+ "deletedAtMs"
+ ],
+ "type": "object"
+ },
"DeploymentApiKeyCreateParams": {
"additionalProperties": false,
"properties": {
@@ -8544,6 +8566,46 @@
],
"type": "object"
},
+ "DeploymentDeletedSessionsListParams": {
+ "additionalProperties": false,
+ "properties": {
+ "after": {
+ "default": null,
+ "description": "Exclusive session ID cursor; each page contains at most 100 entries.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "universeId"
+ ],
+ "type": "object"
+ },
+ "DeploymentDeletedSessionsListResponse": {
+ "properties": {
+ "nextAfter": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sessions": {
+ "items": {
+ "$ref": "#/definitions/DeletedSessionView"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "sessions"
+ ],
+ "type": "object"
+ },
"DeploymentEnvironmentAdoptParams": {
"properties": {
"bindingId": {
@@ -8890,46 +8952,10 @@
],
"type": "object"
},
- "DeploymentSessionAuditListParams": {
- "additionalProperties": false,
- "properties": {
- "limit": {
- "description": "Most recent records, between 1 and 1000; defaults to 100.",
- "format": "uint32",
- "minimum": 0,
- "type": [
- "integer",
- "null"
- ]
- },
- "universeId": {
- "type": [
- "string",
- "null"
- ]
- }
- },
- "type": "object"
- },
- "DeploymentSessionAuditListResponse": {
- "properties": {
- "events": {
- "items": {
- "$ref": "#/definitions/SessionAuditEvent"
- },
- "type": "array"
- }
- },
- "required": [
- "events"
- ],
- "type": "object"
- },
"DeploymentSessionPurgeParams": {
"additionalProperties": false,
"properties": {
"sessionId": {
- "description": "An already deleted session; includes its deleted descendants.",
"type": "string"
},
"universeId": {
@@ -8944,7 +8970,8 @@
},
"DeploymentSessionPurgeResponse": {
"properties": {
- "purgedSessionIds": {
+ "deletedSessionIds": {
+ "description": "Includes soft-deleted history forks and delegated descendants.\nEmpty when the target has already been permanently deleted.",
"items": {
"type": "string"
},
@@ -8952,7 +8979,7 @@
}
},
"required": [
- "purgedSessionIds"
+ "deletedSessionIds"
],
"type": "object"
},
@@ -13869,54 +13896,6 @@
}
]
},
- "SessionAuditEvent": {
- "properties": {
- "action": {
- "type": "string"
- },
- "affectedSessionIds": {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- "attribution": {
- "$ref": "#/definitions/Attribution"
- },
- "cause": {
- "type": "string"
- },
- "createdAtMs": {
- "format": "uint64",
- "minimum": 0,
- "type": "integer"
- },
- "id": {
- "type": "string"
- },
- "outcome": {
- "type": "string"
- },
- "sessionId": {
- "type": "string"
- },
- "universeId": {
- "type": "string"
- }
- },
- "required": [
- "id",
- "universeId",
- "sessionId",
- "action",
- "attribution",
- "cause",
- "affectedSessionIds",
- "createdAtMs",
- "outcome"
- ],
- "type": "object"
- },
"SessionCloseParams": {
"properties": {
"force": {
@@ -14046,6 +14025,10 @@
},
"sessionId": {
"type": "string"
+ },
+ "sharedOnly": {
+ "description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
+ "type": "boolean"
}
},
"required": [
@@ -17837,6 +17820,7 @@
"stop_session",
"close_session",
"delete_session",
+ "set_session_retention",
"create_profile",
"manage_profile",
"create_bot",
diff --git a/crates/api/contract/methods.json b/crates/api/contract/methods.json
index 60ed7c217..090340037 100644
--- a/crates/api/contract/methods.json
+++ b/crates/api/contract/methods.json
@@ -226,7 +226,7 @@
},
{
"access": {
- "action": "delete_session",
+ "action": "set_session_retention",
"kind": "universe"
},
"description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
@@ -279,7 +279,7 @@
"action": "delete_session",
"kind": "universe"
},
- "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
+ "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
"group": "session/delete",
"method": "session/delete",
"params": {
@@ -294,7 +294,7 @@
},
"type": "AgentApiOutcome"
},
- "role": "admin",
+ "role": "operator",
"scope": "universe",
"summary": "Delete closed sessions",
"target": "sessionId"
@@ -3001,31 +3001,31 @@
"access": {
"kind": "deployment"
},
- "description": "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.",
+ "description": "Administrative metadata for soft-deleted sessions, ordered by session ID. Unavailable through universe-scoped methods.",
"group": "deployment/sessions",
- "method": "deployment/sessions/audit/list",
+ "method": "deployment/sessions/deleted/list",
"params": {
"schema": {
- "$ref": "#/definitions/DeploymentSessionAuditListParams"
+ "$ref": "#/definitions/DeploymentDeletedSessionsListParams"
},
- "type": "DeploymentSessionAuditListParams"
+ "type": "DeploymentDeletedSessionsListParams"
},
"result": {
"schema": {
- "$ref": "#/definitions/AgentApiOutcomeOfDeploymentSessionAuditListResponse"
+ "$ref": "#/definitions/AgentApiOutcomeOfDeploymentDeletedSessionsListResponse"
},
- "type": "AgentApiOutcome"
+ "type": "AgentApiOutcome"
},
"role": null,
"scope": "deployment",
- "summary": "Read session lifecycle audit",
+ "summary": "List deleted sessions",
"target": null
},
{
"access": {
"kind": "deployment"
},
- "description": "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.",
+ "description": "Removes a soft-deleted session and its soft-deleted history subtree. Rejects any selected session that has not been soft-deleted. Releases event, checkpoint and blob references; unreferenced blobs follow normal garbage collection. Does not delete external workspaces, environments, backups or Temporal history.",
"group": "deployment/sessions",
"method": "deployment/sessions/purge",
"params": {
@@ -3042,7 +3042,7 @@
},
"role": null,
"scope": "deployment",
- "summary": "Permanently purge deleted sessions",
+ "summary": "Permanently delete a session",
"target": null
},
{
diff --git a/crates/api/contract/openrpc.json b/crates/api/contract/openrpc.json
index ff2e61c41..1980710ed 100644
--- a/crates/api/contract/openrpc.json
+++ b/crates/api/contract/openrpc.json
@@ -1067,7 +1067,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentAdoptResponse": {
+ "AgentApiOutcomeOfDeploymentDeletedSessionsListResponse": {
"properties": {
"notifications": {
"items": {
@@ -1076,7 +1076,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentEnvironmentAdoptResponse"
+ "$ref": "#/components/schemas/DeploymentDeletedSessionsListResponse"
}
},
"required": [
@@ -1084,7 +1084,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderDeleteResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentAdoptResponse": {
"properties": {
"notifications": {
"items": {
@@ -1093,7 +1093,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentEnvironmentProviderDeleteResponse"
+ "$ref": "#/components/schemas/DeploymentEnvironmentAdoptResponse"
}
},
"required": [
@@ -1101,7 +1101,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderListResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderDeleteResponse": {
"properties": {
"notifications": {
"items": {
@@ -1110,7 +1110,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentEnvironmentProviderListResponse"
+ "$ref": "#/components/schemas/DeploymentEnvironmentProviderDeleteResponse"
}
},
"required": [
@@ -1118,7 +1118,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderPutResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderListResponse": {
"properties": {
"notifications": {
"items": {
@@ -1127,7 +1127,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentEnvironmentProviderPutResponse"
+ "$ref": "#/components/schemas/DeploymentEnvironmentProviderListResponse"
}
},
"required": [
@@ -1135,7 +1135,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentEnvironmentProviderReadResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderPutResponse": {
"properties": {
"notifications": {
"items": {
@@ -1144,7 +1144,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentEnvironmentProviderReadResponse"
+ "$ref": "#/components/schemas/DeploymentEnvironmentProviderPutResponse"
}
},
"required": [
@@ -1152,7 +1152,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentProviderBindingDeleteResponse": {
+ "AgentApiOutcomeOfDeploymentEnvironmentProviderReadResponse": {
"properties": {
"notifications": {
"items": {
@@ -1161,7 +1161,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentProviderBindingDeleteResponse"
+ "$ref": "#/components/schemas/DeploymentEnvironmentProviderReadResponse"
}
},
"required": [
@@ -1169,7 +1169,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentProviderBindingPutResponse": {
+ "AgentApiOutcomeOfDeploymentProviderBindingDeleteResponse": {
"properties": {
"notifications": {
"items": {
@@ -1178,7 +1178,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentProviderBindingPutResponse"
+ "$ref": "#/components/schemas/DeploymentProviderBindingDeleteResponse"
}
},
"required": [
@@ -1186,7 +1186,7 @@
],
"type": "object"
},
- "AgentApiOutcomeOfDeploymentSessionAuditListResponse": {
+ "AgentApiOutcomeOfDeploymentProviderBindingPutResponse": {
"properties": {
"notifications": {
"items": {
@@ -1195,7 +1195,7 @@
"type": "array"
},
"result": {
- "$ref": "#/components/schemas/DeploymentSessionAuditListResponse"
+ "$ref": "#/components/schemas/DeploymentProviderBindingPutResponse"
}
},
"required": [
@@ -8272,6 +8272,28 @@
],
"type": "object"
},
+ "DeletedSessionView": {
+ "properties": {
+ "deletedAtMs": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "displayName": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sessionId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "sessionId",
+ "deletedAtMs"
+ ],
+ "type": "object"
+ },
"DeploymentApiKeyCreateParams": {
"additionalProperties": false,
"properties": {
@@ -8544,6 +8566,46 @@
],
"type": "object"
},
+ "DeploymentDeletedSessionsListParams": {
+ "additionalProperties": false,
+ "properties": {
+ "after": {
+ "default": null,
+ "description": "Exclusive session ID cursor; each page contains at most 100 entries.",
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "universeId": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "universeId"
+ ],
+ "type": "object"
+ },
+ "DeploymentDeletedSessionsListResponse": {
+ "properties": {
+ "nextAfter": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "sessions": {
+ "items": {
+ "$ref": "#/components/schemas/DeletedSessionView"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "sessions"
+ ],
+ "type": "object"
+ },
"DeploymentEnvironmentAdoptParams": {
"properties": {
"bindingId": {
@@ -8890,46 +8952,10 @@
],
"type": "object"
},
- "DeploymentSessionAuditListParams": {
- "additionalProperties": false,
- "properties": {
- "limit": {
- "description": "Most recent records, between 1 and 1000; defaults to 100.",
- "format": "uint32",
- "minimum": 0,
- "type": [
- "integer",
- "null"
- ]
- },
- "universeId": {
- "type": [
- "string",
- "null"
- ]
- }
- },
- "type": "object"
- },
- "DeploymentSessionAuditListResponse": {
- "properties": {
- "events": {
- "items": {
- "$ref": "#/components/schemas/SessionAuditEvent"
- },
- "type": "array"
- }
- },
- "required": [
- "events"
- ],
- "type": "object"
- },
"DeploymentSessionPurgeParams": {
"additionalProperties": false,
"properties": {
"sessionId": {
- "description": "An already deleted session; includes its deleted descendants.",
"type": "string"
},
"universeId": {
@@ -8944,7 +8970,8 @@
},
"DeploymentSessionPurgeResponse": {
"properties": {
- "purgedSessionIds": {
+ "deletedSessionIds": {
+ "description": "Includes soft-deleted history forks and delegated descendants.\nEmpty when the target has already been permanently deleted.",
"items": {
"type": "string"
},
@@ -8952,7 +8979,7 @@
}
},
"required": [
- "purgedSessionIds"
+ "deletedSessionIds"
],
"type": "object"
},
@@ -13869,54 +13896,6 @@
}
]
},
- "SessionAuditEvent": {
- "properties": {
- "action": {
- "type": "string"
- },
- "affectedSessionIds": {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- "attribution": {
- "$ref": "#/components/schemas/Attribution"
- },
- "cause": {
- "type": "string"
- },
- "createdAtMs": {
- "format": "uint64",
- "minimum": 0,
- "type": "integer"
- },
- "id": {
- "type": "string"
- },
- "outcome": {
- "type": "string"
- },
- "sessionId": {
- "type": "string"
- },
- "universeId": {
- "type": "string"
- }
- },
- "required": [
- "id",
- "universeId",
- "sessionId",
- "action",
- "attribution",
- "cause",
- "affectedSessionIds",
- "createdAtMs",
- "outcome"
- ],
- "type": "object"
- },
"SessionCloseParams": {
"properties": {
"force": {
@@ -14046,6 +14025,10 @@
},
"sessionId": {
"type": "string"
+ },
+ "sharedOnly": {
+ "description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
+ "type": "boolean"
}
},
"required": [
@@ -17837,6 +17820,7 @@
"stop_session",
"close_session",
"delete_session",
+ "set_session_retention",
"create_profile",
"manage_profile",
"create_bot",
@@ -19025,7 +19009,7 @@
},
"summary": "Replace session retention",
"x-lightspeed-access": {
- "action": "delete_session",
+ "action": "set_session_retention",
"kind": "universe"
},
"x-lightspeed-group": "session/delete",
@@ -19061,7 +19045,7 @@
"x-lightspeed-target": "sessionId"
},
{
- "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
+ "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
"name": "session/delete",
"paramStructure": "by-name",
"params": [
@@ -19085,7 +19069,7 @@
"kind": "universe"
},
"x-lightspeed-group": "session/delete",
- "x-lightspeed-role": "admin",
+ "x-lightspeed-role": "operator",
"x-lightspeed-target": "sessionId"
},
{
@@ -22111,25 +22095,25 @@
"x-lightspeed-target": null
},
{
- "description": "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.",
- "name": "deployment/sessions/audit/list",
+ "description": "Administrative metadata for soft-deleted sessions, ordered by session ID. Unavailable through universe-scoped methods.",
+ "name": "deployment/sessions/deleted/list",
"paramStructure": "by-name",
"params": [
{
"name": "params",
"required": true,
"schema": {
- "$ref": "#/components/schemas/DeploymentSessionAuditListParams"
+ "$ref": "#/components/schemas/DeploymentDeletedSessionsListParams"
}
}
],
"result": {
"name": "result",
"schema": {
- "$ref": "#/components/schemas/AgentApiOutcomeOfDeploymentSessionAuditListResponse"
+ "$ref": "#/components/schemas/AgentApiOutcomeOfDeploymentDeletedSessionsListResponse"
}
},
- "summary": "Read session lifecycle audit",
+ "summary": "List deleted sessions",
"x-lightspeed-access": {
"kind": "deployment"
},
@@ -22138,7 +22122,7 @@
"x-lightspeed-target": null
},
{
- "description": "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.",
+ "description": "Removes a soft-deleted session and its soft-deleted history subtree. Rejects any selected session that has not been soft-deleted. Releases event, checkpoint and blob references; unreferenced blobs follow normal garbage collection. Does not delete external workspaces, environments, backups or Temporal history.",
"name": "deployment/sessions/purge",
"paramStructure": "by-name",
"params": [
@@ -22156,7 +22140,7 @@
"$ref": "#/components/schemas/AgentApiOutcomeOfDeploymentSessionPurgeResponse"
}
},
- "summary": "Permanently purge deleted sessions",
+ "summary": "Permanently delete a session",
"x-lightspeed-access": {
"kind": "deployment"
},
diff --git a/crates/api/src/access.rs b/crates/api/src/access.rs
index 1c1b92bc7..feac11115 100644
--- a/crates/api/src/access.rs
+++ b/crates/api/src/access.rs
@@ -344,6 +344,7 @@ pub enum UniverseAction {
StopSession,
CloseSession,
DeleteSession,
+ SetSessionRetention,
/// Share an unshared session with the universe.
ShareSession,
CreateProfile,
@@ -396,11 +397,11 @@ impl MethodAccess {
};
Some(match action {
Read => RecommendedRole::Viewer,
- DeleteSession => RecommendedRole::Admin,
+ SetSessionRetention => RecommendedRole::Admin,
CreateSession | ControlSession | StopSession | CloseSession | ShareSession
| InvokeBot | UseResource => RecommendedRole::Contributor,
- ConfigureSession | CreateProfile | ManageProfile | CreateBot | ManageBot
- | ConfigureResource | CreateWorkspace => RecommendedRole::Operator,
+ DeleteSession | ConfigureSession | CreateProfile | ManageProfile | CreateBot
+ | ManageBot | ConfigureResource | CreateWorkspace => RecommendedRole::Operator,
})
}
}
diff --git a/crates/api/src/deployment.rs b/crates/api/src/deployment.rs
index ee5621c0f..393efbce6 100644
--- a/crates/api/src/deployment.rs
+++ b/crates/api/src/deployment.rs
@@ -40,6 +40,48 @@ pub const METHOD_DEPLOYMENT_PROVIDER_BINDINGS_DELETE: &str =
pub const METHOD_DEPLOYMENT_ENVIRONMENTS_ADOPT: &str = "deployment/environments/adopt";
pub const METHOD_DEPLOYMENT_CHANNELS_ACCOUNTS_LIST: &str = "deployment/channels/accounts/list";
+pub const METHOD_DEPLOYMENT_SESSIONS_DELETED_LIST: &str = "deployment/sessions/deleted/list";
+pub const METHOD_DEPLOYMENT_SESSIONS_PURGE: &str = "deployment/sessions/purge";
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct DeploymentDeletedSessionsListParams {
+ pub universe_id: String,
+ /// Exclusive session ID cursor; each page contains at most 100 entries.
+ #[serde(default)]
+ pub after: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DeletedSessionView {
+ pub session_id: String,
+ pub display_name: Option,
+ pub deleted_at_ms: i64,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DeploymentDeletedSessionsListResponse {
+ pub sessions: Vec,
+ pub next_after: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct DeploymentSessionPurgeParams {
+ pub universe_id: String,
+ pub session_id: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DeploymentSessionPurgeResponse {
+ /// Includes soft-deleted history forks and delegated descendants.
+ /// Empty when the target has already been permanently deleted.
+ pub deleted_session_ids: Vec,
+}
+
pub fn is_deployment_method(method: &str) -> bool {
method.starts_with(DEPLOYMENT_METHOD_PREFIX)
}
@@ -400,6 +442,23 @@ pub struct DeploymentChannelAccountListResponse {
#[async_trait]
pub trait DeploymentApiService: Send + Sync {
+ async fn list_deleted_sessions(
+ &self,
+ _params: DeploymentDeletedSessionsListParams,
+ ) -> Result, AgentApiError> {
+ Err(AgentApiError::internal(
+ "deleted session administration is unavailable",
+ ))
+ }
+ async fn purge_session(
+ &self,
+ _params: DeploymentSessionPurgeParams,
+ ) -> Result, AgentApiError> {
+ Err(AgentApiError::internal(
+ "deleted session administration is unavailable",
+ ))
+ }
+
async fn list_deployment_provider_bindings(
&self,
_params: DeploymentUniverseReadParams,
@@ -577,6 +636,11 @@ macro_rules! deployment_api_methods {
}
deployment_api_methods! {
+ METHOD_DEPLOYMENT_SESSIONS_DELETED_LIST => list_deleted_sessions(DeploymentDeletedSessionsListParams) -> DeploymentDeletedSessionsListResponse =>
+ ["List deleted sessions", "Administrative metadata for soft-deleted sessions, ordered by session ID. Unavailable through universe-scoped methods."], access: MethodAccess::Deployment,
+ METHOD_DEPLOYMENT_SESSIONS_PURGE => purge_session(DeploymentSessionPurgeParams) -> DeploymentSessionPurgeResponse =>
+ ["Permanently delete a session", "Removes a soft-deleted session and its soft-deleted history subtree. Rejects any selected session that has not been soft-deleted. Releases event, checkpoint and blob references; unreferenced blobs follow normal garbage collection. Does not delete external workspaces, environments, backups or Temporal history."], access: MethodAccess::Deployment,
+
METHOD_DEPLOYMENT_PROVIDER_BINDINGS_LIST => list_deployment_provider_bindings(DeploymentUniverseReadParams) -> EnvironmentProviderBindingListResponse =>
["List a universe's deployment provider bindings", "Deployment configuration inventory of one universe's provider bindings."], access: MethodAccess::Deployment,
diff --git a/crates/api/src/rpc.rs b/crates/api/src/rpc.rs
index 1209a3744..b3fbda63a 100644
--- a/crates/api/src/rpc.rs
+++ b/crates/api/src/rpc.rs
@@ -351,11 +351,11 @@ api_methods! {
METHOD_SESSION_METADATA_PUT => put_session_metadata(SessionMetadataPutParams) -> SessionMetadataPutResponse =>
["Replace session metadata", "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched."], access: MethodAccess::Universe(UniverseAction::ConfigureSession),
METHOD_SESSION_RETENTION_PUT => put_session_retention(SessionRetentionPutParams) -> SessionRetentionPutResponse =>
- ["Replace session retention", "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
+ ["Replace session retention", "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it."], access: MethodAccess::Universe(UniverseAction::SetSessionRetention),
METHOD_SESSION_CLOSE => close_session(SessionCloseParams) -> SessionCloseResponse =>
["Close a session", "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable."], access: MethodAccess::Universe(UniverseAction::CloseSession),
METHOD_SESSION_DELETE => delete_session(SessionDeleteParams) -> SessionDeleteResponse =>
- ["Delete closed sessions", "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
+ ["Delete closed sessions", "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
METHOD_SESSION_SHARE => share_session(SessionShareParams) -> SessionShareResponse =>
["Share a session with the universe", "Moves an unshared root session to universe visibility, one way; its delegated children follow it. Refused on a bot's session, a delegated child, and a session already shared. Core applies it for any caller of the method; who may share is the caller's gate's decision."], access: MethodAccess::Universe(UniverseAction::ShareSession),
METHOD_SESSION_EVENTS_READ => read_session_events(SessionEventsReadParams) -> SessionEventsReadResponse =>
diff --git a/crates/api/src/schema_export.rs b/crates/api/src/schema_export.rs
index de417f937..e3f5e6065 100644
--- a/crates/api/src/schema_export.rs
+++ b/crates/api/src/schema_export.rs
@@ -205,13 +205,13 @@ mod tests {
methods.sort_unstable();
methods.dedup();
assert_eq!(methods.len(), total, "duplicate method in manifest");
- assert_eq!(total, 138);
+ assert_eq!(total, 140);
assert_eq!(
manifest
.iter()
.filter(|spec| spec.scope == crate::MethodScope::Deployment)
.count(),
- 18
+ 20
);
}
@@ -303,7 +303,8 @@ mod tests {
let has_session_id = !params["properties"]["sessionId"].is_null();
let session_scoped = (method.starts_with("session/")
&& method != crate::METHOD_SESSION_LIST)
- || method == crate::METHOD_BOTS_SESSIONS_ROTATE;
+ || method == crate::METHOD_BOTS_SESSIONS_ROTATE
+ || method == crate::METHOD_DEPLOYMENT_SESSIONS_PURGE;
assert_eq!(
has_session_id, session_scoped,
"{method}: sessionId param presence must match its session/ prefix"
diff --git a/crates/api/src/sessions.rs b/crates/api/src/sessions.rs
index b1350b13e..a06ff3043 100644
--- a/crates/api/src/sessions.rs
+++ b/crates/api/src/sessions.rs
@@ -1192,6 +1192,10 @@ pub struct SessionDeleteParams {
/// target to be a closed retention-tree leaf.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cascade: bool,
+ /// Atomically require every selected session's audience to be shared.
+ /// Delegating services use this guard for operators deleting shared work.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub shared_only: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
diff --git a/crates/cli/src/session_cli.rs b/crates/cli/src/session_cli.rs
index d5e3c8c8c..33bbbb763 100644
--- a/crates/cli/src/session_cli.rs
+++ b/crates/cli/src/session_cli.rs
@@ -548,6 +548,7 @@ async fn delete(args: DeleteArgs) -> Result<()> {
for session_id in targets.ids {
match client
.delete_session(api::SessionDeleteParams {
+ shared_only: false,
session_id: session_id.clone(),
cascade: args.cascade,
})
diff --git a/crates/store-pg/migrations/011_session_deletion.sql b/crates/store-pg/migrations/011_session_deletion.sql
new file mode 100644
index 000000000..ad692bd86
--- /dev/null
+++ b/crates/store-pg/migrations/011_session_deletion.sql
@@ -0,0 +1,4 @@
+-- Soft deletion is a storage lifecycle, separate from the event log.
+ALTER TABLE sessions ADD COLUMN deleted_at_ms bigint;
+ALTER TABLE sessions ADD CONSTRAINT sessions_deleted_closed CHECK
+ (deleted_at_ms IS NULL OR lifecycle_status = 'closed');
diff --git a/crates/store-pg/src/access.rs b/crates/store-pg/src/access.rs
index 58b97ba4f..26cadd850 100644
--- a/crates/store-pg/src/access.rs
+++ b/crates/store-pg/src/access.rs
@@ -57,7 +57,7 @@ fn error(error: impl std::fmt::Display) -> AccessStoreError {
/// Joins the root of session `s` as `r`. A left join: a root that is gone
/// leaves its tree unshared and created by no one.
-pub(crate) const SESSION_ROOT_JOIN: &str = "LEFT JOIN sessions r ON r.universe_id = s.universe_id AND r.session_id = COALESCE(s.origin_root_session_id, s.session_id)";
+pub(crate) const SESSION_ROOT_JOIN: &str = "LEFT JOIN sessions r ON r.universe_id = s.universe_id AND r.deleted_at_ms IS NULL AND r.session_id = COALESCE(s.origin_root_session_id, s.session_id)";
/// The bot whose worker controls session `s`'s tree, if any.
const SESSION_BOT: &str = "COALESCE(r.bot_id, s.bot_id)";
@@ -177,6 +177,12 @@ impl PgAccessStore {
universe: Uuid,
resource: &ResourceRef,
) -> Result {
+ if matches!(resource, ResourceRef::Session(_)) {
+ return Ok(self
+ .session_access(universe, resource.id())
+ .await?
+ .is_some());
+ }
let (table, id_column) = content_table(resource);
sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"SELECT EXISTS(SELECT 1 FROM {table} WHERE universe_id=$1 AND {id_column}=$2)"
@@ -201,7 +207,7 @@ impl PgAccessStore {
) -> Result<(), AccessStoreError> {
sqlx::query(
"UPDATE sessions SET created_by = $3, visibility = $4, bot_id = $5
- WHERE universe_id = $1 AND session_id = $2 AND created_by IS NULL",
+ WHERE deleted_at_ms IS NULL AND universe_id = $1 AND session_id = $2 AND created_by IS NULL",
)
.bind(universe)
.bind(session)
@@ -245,7 +251,7 @@ impl PgAccessStore {
"SELECT s.origin_parent_session_id, COALESCE(s.origin_root_session_id, s.session_id) AS root_session_id,
{SESSION_BOT} AS root_bot_id, {summary}
FROM sessions s {SESSION_ROOT_JOIN}
- WHERE s.universe_id = $1 AND s.session_id = $2"
+ WHERE s.deleted_at_ms IS NULL AND s.universe_id = $1 AND s.session_id = $2"
)))
.bind(universe)
.bind(session)
@@ -299,7 +305,7 @@ impl PgAccessStore {
) -> Result {
Ok(sqlx::query(
"UPDATE sessions SET visibility = 'universe'
- WHERE universe_id = $1 AND session_id = $2 AND origin_root_session_id IS NULL
+ WHERE deleted_at_ms IS NULL AND universe_id = $1 AND session_id = $2 AND origin_root_session_id IS NULL
AND bot_id IS NULL AND COALESCE(visibility, 'restricted') = 'restricted'",
)
.bind(universe)
@@ -322,7 +328,7 @@ impl PgAccessStore {
"WITH RECURSIVE tree(session_id) AS (
SELECT $2::text UNION SELECT child.session_id FROM sessions child JOIN tree parent
ON (child.source_seq IS NOT NULL AND child.source_session_id=parent.session_id)
- OR child.origin_parent_session_id=parent.session_id WHERE child.universe_id=$1)
+ OR child.origin_parent_session_id=parent.session_id WHERE child.universe_id=$1 AND child.deleted_at_ms IS NULL)
SELECT session_id FROM tree",
)
.bind(universe)
diff --git a/crates/store-pg/src/deployment.rs b/crates/store-pg/src/deployment.rs
index 76d812c8a..b0f8b1662 100644
--- a/crates/store-pg/src/deployment.rs
+++ b/crates/store-pg/src/deployment.rs
@@ -31,9 +31,9 @@ const UNIVERSE_STATS_SELECT: &str = r#"
u.slug,
u.created_at_ms,
(SELECT max(s.updated_at_ms) FROM sessions s
- WHERE s.universe_id = u.universe_id) AS last_activity_at_ms,
+ WHERE s.universe_id = u.universe_id AND s.deleted_at_ms IS NULL) AS last_activity_at_ms,
(SELECT count(*) FROM sessions s
- WHERE s.universe_id = u.universe_id) AS sessions,
+ WHERE s.universe_id = u.universe_id AND s.deleted_at_ms IS NULL) AS sessions,
(SELECT count(*) FROM vfs_workspaces w
WHERE w.universe_id = u.universe_id) AS workspaces,
(SELECT count(*) FROM agent_profiles p
diff --git a/crates/store-pg/src/lib.rs b/crates/store-pg/src/lib.rs
index 4bf23ff2e..8a2b44a43 100644
--- a/crates/store-pg/src/lib.rs
+++ b/crates/store-pg/src/lib.rs
@@ -22,6 +22,7 @@ mod object;
mod profile;
mod providers;
mod session;
+pub use session::PurgeSessionError;
mod shared;
mod vfs;
diff --git a/crates/store-pg/src/migrations.rs b/crates/store-pg/src/migrations.rs
index c9d186252..d0f3bf499 100644
--- a/crates/store-pg/src/migrations.rs
+++ b/crates/store-pg/src/migrations.rs
@@ -108,9 +108,14 @@ pub const MIGRATIONS: &[EmbeddedMigration] = &[
name: "model_defaults",
sql: include_str!("../migrations/010_model_defaults.sql"),
},
+ EmbeddedMigration {
+ version: 11,
+ name: "session_deletion",
+ sql: include_str!("../migrations/011_session_deletion.sql"),
+ },
];
-pub const REQUIRED_SCHEMA_REVISION: i64 = 10;
+pub const REQUIRED_SCHEMA_REVISION: i64 = 11;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SchemaStatus {
diff --git a/crates/store-pg/src/session.rs b/crates/store-pg/src/session.rs
index 3db76e991..54d1ff83a 100644
--- a/crates/store-pg/src/session.rs
+++ b/crates/store-pg/src/session.rs
@@ -66,7 +66,7 @@ impl PgStore {
r#"
SELECT {SESSION_COLUMNS}, {SESSION_ACTIVITY}
FROM sessions
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
FOR UPDATE
"#,
);
@@ -157,7 +157,7 @@ impl PgStore {
closed_at_seq = $6,
managed = $7,
closed_at_ms = $8
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
"#,
)
.bind(self.config.universe_id)
@@ -473,7 +473,7 @@ impl PgStore {
LEFT JOIN session_activity sa
ON sa.universe_id = s.universe_id AND sa.session_id = s.session_id
{root_join}
- WHERE s.universe_id = $1
+ WHERE s.universe_id = $1 AND s.deleted_at_ms IS NULL
AND ($2::bigint IS NULL OR (s.updated_at_ms, s.session_id) < ($2, $3))
AND ($4::text IS NULL OR s.origin_parent_session_id = $4)
{closed_predicate}
@@ -674,7 +674,7 @@ impl SessionStore for PgStore {
r#"
SELECT {SESSION_COLUMNS}, {SESSION_ACTIVITY}
FROM sessions
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
"#,
);
let row = sqlx::query(sqlx::AssertSqlSafe(query))
@@ -713,7 +713,7 @@ impl SessionStore for PgStore {
r#"
UPDATE sessions
SET display_name = $3
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
RETURNING {SESSION_COLUMNS}, {SESSION_ACTIVITY}
"#,
);
@@ -742,7 +742,7 @@ impl SessionStore for PgStore {
r#"
UPDATE sessions
SET metadata_json = $3
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
RETURNING {SESSION_COLUMNS}, {SESSION_ACTIVITY}
"#,
);
@@ -794,7 +794,7 @@ impl SessionStore for PgStore {
r#"
UPDATE sessions
SET delete_after_close_ms = $3
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
RETURNING {SESSION_COLUMNS}, {SESSION_ACTIVITY}
"#,
);
@@ -827,7 +827,7 @@ impl SessionStore for PgStore {
r#"
SELECT {SESSION_COLUMNS}, {SESSION_ACTIVITY}
FROM sessions
- WHERE universe_id = $1
+ WHERE universe_id = $1 AND deleted_at_ms IS NULL
AND retention_root_session_id = session_id
AND lifecycle_status = 'closed'
AND delete_at_ms IS NOT NULL
@@ -850,137 +850,8 @@ impl SessionStore for PgStore {
&self,
request: DeleteClosedSessions,
) -> Result {
- let target_snapshot = self
- .load_session(&request.session_id)
- .await?
- .ok_or_else(|| SessionStoreError::SessionNotFound {
- session_id: request.session_id.clone(),
- })?;
- let mut tx = self
- .pool
- .begin()
+ self.delete_closed_sessions_with_visibility(request, false)
.await
- .map_err(|error| session_sql_error("begin delete session transaction", error))?;
- let root = lock_session(
- &mut tx,
- self.config.universe_id,
- &target_snapshot.retention_root_session_id,
- "lock retention root for delete",
- )
- .await?;
- let target = if target_snapshot.session_id == root.session_id {
- root
- } else {
- lock_session(
- &mut tx,
- self.config.universe_id,
- &request.session_id,
- "lock session for delete",
- )
- .await?
- };
- if let Some(now_ms) = request.due_at_or_before_ms
- && (target.retention_root_session_id != target.session_id
- || !target
- .delete_at_ms
- .is_some_and(|deadline| deadline <= now_ms))
- {
- return Err(SessionStoreError::SessionRetentionNotDue {
- session_id: target.session_id,
- });
- }
-
- let session_ids: Vec = sqlx::query_scalar(
- r#"
- WITH RECURSIVE tree(session_id) AS (
- SELECT $2::text
- UNION
- SELECT child.session_id
- FROM sessions AS child
- JOIN tree AS parent
- ON (child.source_seq IS NOT NULL
- AND child.source_session_id = parent.session_id)
- OR child.origin_parent_session_id = parent.session_id
- WHERE child.universe_id = $1
- )
- SELECT session_id FROM tree ORDER BY session_id
- "#,
- )
- .bind(self.config.universe_id)
- .bind(request.session_id.as_str())
- .fetch_all(&mut *tx)
- .await
- .map_err(|error| session_sql_error("list session retention subtree", error))?;
- if !request.cascade && session_ids.len() > 1 {
- return Err(SessionStoreError::SessionHasChildren {
- session_id: request.session_id,
- });
- }
- let selected_ids = if request.cascade {
- session_ids
- } else {
- vec![request.session_id.as_str().to_owned()]
- };
- let query = format!(
- r#"
- SELECT {SESSION_COLUMNS}, {SESSION_ACTIVITY}
- FROM sessions
- WHERE universe_id = $1 AND session_id = ANY($2)
- ORDER BY session_id
- FOR UPDATE
- "#,
- );
- let rows = sqlx::query(sqlx::AssertSqlSafe(query))
- .bind(self.config.universe_id)
- .bind(&selected_ids)
- .fetch_all(&mut *tx)
- .await
- .map_err(|error| session_sql_error("lock session retention subtree", error))?;
- let records = rows
- .iter()
- .map(session_record_from_row)
- .collect::, _>>()?;
- for record in &records {
- if record.lifecycle_status != SessionLifecycleStatus::Closed {
- let error = if record.session_id == request.session_id {
- SessionStoreError::SessionNotClosed {
- session_id: record.session_id.clone(),
- lifecycle_status: record.lifecycle_status,
- }
- } else {
- SessionStoreError::SessionTreeNotClosed {
- session_id: record.session_id.clone(),
- lifecycle_status: record.lifecycle_status,
- }
- };
- return Err(error);
- }
- }
- sqlx::query(
- r#"
- DELETE FROM sessions
- WHERE universe_id = $1 AND session_id = ANY($2)
- "#,
- )
- .bind(self.config.universe_id)
- .bind(&selected_ids)
- .execute(&mut *tx)
- .await
- .map_err(|error| session_sql_error("delete session subtree", error))?;
- tx.commit()
- .await
- .map_err(|error| session_sql_error("commit delete session", error))?;
- let deleted_session_ids = selected_ids
- .into_iter()
- .map(SessionId::parse)
- .collect::, _>>()
- .map_err(|error| SessionStoreError::Store {
- message: format!("decode deleted session id: {error}"),
- })?;
- Ok(DeleteClosedSessionsResult {
- target,
- deleted_session_ids,
- })
}
async fn create_cloned_session(
@@ -1219,6 +1090,7 @@ impl SessionStore for PgStore {
if request.limit == 0 {
return Err(SessionStoreError::InvalidLimit { limit: 0 });
}
+ self.load_session_required(&request.session_id).await?;
if request.after >= request.through {
return Ok(SessionPage {
entries: Vec::new(),
@@ -1257,6 +1129,7 @@ impl SessionStore for PgStore {
byte_len, created_at_ms
FROM session_checkpoints
WHERE universe_id = $1 AND session_id = $2
+ AND EXISTS (SELECT 1 FROM sessions s WHERE s.universe_id = $1 AND s.session_id = $2 AND s.deleted_at_ms IS NULL)
"#,
)
.bind(self.config.universe_id)
@@ -1328,6 +1201,18 @@ impl SessionStore for PgStore {
request: AdvanceSessionCheckpoint,
) -> Result {
let checkpoint = request.checkpoint;
+ let mut tx = self
+ .pool
+ .begin()
+ .await
+ .map_err(|e| session_sql_error("begin checkpoint transaction", e))?;
+ lock_session(
+ &mut tx,
+ self.config.universe_id,
+ &checkpoint.session_id,
+ "lock checkpoint session",
+ )
+ .await?;
let digest = checkpoint
.state_ref
.as_str()
@@ -1380,9 +1265,12 @@ impl SessionStore for PgStore {
checkpoint.created_at_ms,
"checkpoint created time",
)?)
- .execute(&self.pool)
+ .execute(&mut *tx)
.await
.map_err(|error| session_sql_error("advance session checkpoint", error))?;
+ tx.commit()
+ .await
+ .map_err(|e| session_sql_error("commit checkpoint transaction", e))?;
Ok(result.rows_affected() == 1)
}
@@ -1580,7 +1468,7 @@ async fn origin_counts_in_tx(
count(*) AS descendants,
count(*) FILTER (WHERE lifecycle_status <> 'closed') AS open_descendants
FROM sessions
- WHERE universe_id = $1 AND origin_root_session_id = $2
+ WHERE universe_id = $1 AND origin_root_session_id = $2 AND deleted_at_ms IS NULL
"#,
)
.bind(universe_id)
@@ -1672,7 +1560,7 @@ async fn lock_session(
r#"
SELECT {SESSION_COLUMNS}, {SESSION_ACTIVITY}
FROM sessions
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
FOR UPDATE
"#,
);
@@ -1748,7 +1636,7 @@ async fn append_events_in_tx(
closed_at_seq = $6,
managed = $7,
closed_at_ms = $8
- WHERE universe_id = $1 AND session_id = $2
+ WHERE universe_id = $1 AND session_id = $2 AND deleted_at_ms IS NULL
"#,
)
.bind(universe_id)
@@ -1882,3 +1770,247 @@ async fn write_activity(
.map_err(|error| session_sql_error("write session activity", error))?;
Ok(())
}
+
+impl PgStore {
+ pub async fn session_is_deleted(&self, session_id: &str) -> Result {
+ sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM sessions WHERE universe_id=$1 AND session_id=$2 AND deleted_at_ms IS NOT NULL)")
+ .bind(self.config.universe_id).bind(session_id).fetch_one(&self.pool).await
+ .map_err(|error| session_sql_error("check deleted session", error))
+ }
+
+ /// Recreated bots keep their previous conversations and start fresh generations.
+ pub async fn previous_bot_session_ids(
+ &self,
+ bot_id: &api::BotId,
+ created_at_ms: i64,
+ ) -> Result, SessionStoreError> {
+ sqlx::query_scalar("SELECT session_id FROM sessions WHERE universe_id=$1 AND bot_id=$2 AND created_at_ms < $3")
+ .bind(self.config.universe_id).bind(bot_id.as_str()).bind(created_at_ms)
+ .fetch_all(&self.pool).await.map_err(|error| session_sql_error("read previous bot sessions", error))
+ }
+}
+
+/// Permanent deletion is separate from ordinary session storage operations.
+#[derive(Debug, thiserror::Error)]
+pub enum PurgeSessionError {
+ #[error("session {session_id} must be soft-deleted before permanent deletion")]
+ NotDeleted { session_id: String },
+ #[error("postgres failure: {0}")]
+ Postgres(#[from] sqlx::Error),
+}
+
+impl PgStore {
+ /// Administrative metadata only; ordinary lists never include these rows.
+ pub async fn list_deleted_sessions(
+ &self,
+ after: Option<&str>,
+ limit: i64,
+ ) -> Result, i64)>, sqlx::Error> {
+ sqlx::query_as("SELECT session_id, display_name, deleted_at_ms FROM sessions WHERE universe_id=$1 AND deleted_at_ms IS NOT NULL AND ($2::text IS NULL OR session_id > $2) ORDER BY session_id LIMIT $3")
+ .bind(self.config.universe_id).bind(after).bind(limit).fetch_all(&self.pool).await
+ }
+
+ /// Removes a soft-deleted history subtree. Foreign keys release its events,
+ /// checkpoints and CAS roots; shared blobs remain governed by normal GC.
+ pub async fn purge_deleted_session(
+ &self,
+ session_id: &str,
+ ) -> Result, PurgeSessionError> {
+ let mut tx = self.pool.begin().await?;
+ // Match creation/deletion lock order, including a still-visible ancestor.
+ sqlx::query("SELECT session_id FROM sessions WHERE universe_id=$1 AND session_id=(SELECT retention_root_session_id FROM sessions WHERE universe_id=$1 AND session_id=$2) FOR UPDATE")
+ .bind(self.config.universe_id).bind(session_id).fetch_optional(&mut *tx).await?;
+ let rows: Vec<(String, Option)> = sqlx::query_as(r#"
+ WITH RECURSIVE tree(session_id) AS (
+ SELECT session_id FROM sessions WHERE universe_id=$1 AND session_id=$2
+ UNION
+ SELECT child.session_id FROM sessions child JOIN tree parent
+ ON (child.source_seq IS NOT NULL AND child.source_session_id=parent.session_id)
+ OR child.origin_parent_session_id=parent.session_id
+ WHERE child.universe_id=$1
+ )
+ SELECT session_id, deleted_at_ms FROM sessions WHERE universe_id=$1 AND session_id IN (SELECT session_id FROM tree)
+ ORDER BY session_id FOR UPDATE
+ "#).bind(self.config.universe_id).bind(session_id).fetch_all(&mut *tx).await?;
+ for (id, deleted_at) in &rows {
+ if deleted_at.is_none() {
+ return Err(PurgeSessionError::NotDeleted {
+ session_id: id.clone(),
+ });
+ }
+ }
+ let ids: Vec = rows.into_iter().map(|(id, _)| id).collect();
+ sqlx::query("DELETE FROM sessions WHERE universe_id=$1 AND session_id=ANY($2)")
+ .bind(self.config.universe_id)
+ .bind(&ids)
+ .execute(&mut *tx)
+ .await?;
+ tx.commit().await?;
+ Ok(ids)
+ }
+}
+
+impl PgStore {
+ pub async fn delete_closed_sessions_with_visibility(
+ &self,
+ request: DeleteClosedSessions,
+ shared_only: bool,
+ ) -> Result {
+ let target_snapshot = self
+ .load_session(&request.session_id)
+ .await?
+ .ok_or_else(|| SessionStoreError::SessionNotFound {
+ session_id: request.session_id.clone(),
+ })?;
+ let mut tx = self
+ .pool
+ .begin()
+ .await
+ .map_err(|error| session_sql_error("begin delete session transaction", error))?;
+ let root = lock_session(
+ &mut tx,
+ self.config.universe_id,
+ &target_snapshot.retention_root_session_id,
+ "lock retention root for delete",
+ )
+ .await?;
+ let target = if target_snapshot.session_id == root.session_id {
+ root
+ } else {
+ lock_session(
+ &mut tx,
+ self.config.universe_id,
+ &request.session_id,
+ "lock session for delete",
+ )
+ .await?
+ };
+ if let Some(now_ms) = request.due_at_or_before_ms
+ && (target.retention_root_session_id != target.session_id
+ || !target
+ .delete_at_ms
+ .is_some_and(|deadline| deadline <= now_ms))
+ {
+ return Err(SessionStoreError::SessionRetentionNotDue {
+ session_id: target.session_id,
+ });
+ }
+
+ let session_ids: Vec = sqlx::query_scalar(
+ r#"
+ WITH RECURSIVE tree(session_id) AS (
+ SELECT $2::text
+ UNION
+ SELECT child.session_id
+ FROM sessions AS child
+ JOIN tree AS parent
+ ON (child.source_seq IS NOT NULL
+ AND child.source_session_id = parent.session_id)
+ OR child.origin_parent_session_id = parent.session_id
+ WHERE child.universe_id = $1 AND child.deleted_at_ms IS NULL
+ )
+ SELECT session_id FROM tree ORDER BY session_id
+ "#,
+ )
+ .bind(self.config.universe_id)
+ .bind(request.session_id.as_str())
+ .fetch_all(&mut *tx)
+ .await
+ .map_err(|error| session_sql_error("list session retention subtree", error))?;
+ if !request.cascade && session_ids.len() > 1 {
+ return Err(SessionStoreError::SessionHasChildren {
+ session_id: request.session_id,
+ });
+ }
+ let selected_ids = if request.cascade {
+ session_ids
+ } else {
+ vec![request.session_id.as_str().to_owned()]
+ };
+ let query = format!(
+ r#"
+ SELECT {SESSION_COLUMNS}, {SESSION_ACTIVITY}
+ FROM sessions
+ WHERE universe_id = $1 AND session_id = ANY($2) AND deleted_at_ms IS NULL
+ ORDER BY session_id
+ FOR UPDATE
+ "#,
+ );
+ let rows = sqlx::query(sqlx::AssertSqlSafe(query))
+ .bind(self.config.universe_id)
+ .bind(&selected_ids)
+ .fetch_all(&mut *tx)
+ .await
+ .map_err(|error| session_sql_error("lock session retention subtree", error))?;
+ let records = rows
+ .iter()
+ .map(session_record_from_row)
+ .collect::, _>>()?;
+ for record in &records {
+ if record.lifecycle_status != SessionLifecycleStatus::Closed {
+ let error = if record.session_id == request.session_id {
+ SessionStoreError::SessionNotClosed {
+ session_id: record.session_id.clone(),
+ lifecycle_status: record.lifecycle_status,
+ }
+ } else {
+ SessionStoreError::SessionTreeNotClosed {
+ session_id: record.session_id.clone(),
+ lifecycle_status: record.lifecycle_status,
+ }
+ };
+ return Err(error);
+ }
+ }
+ if shared_only {
+ let query = format!(
+ "SELECT EXISTS(SELECT 1 FROM sessions s {} WHERE s.universe_id=$1 AND s.session_id=ANY($2) AND {} <> 'universe')",
+ crate::access::SESSION_ROOT_JOIN,
+ crate::access::SESSION_VISIBILITY
+ );
+ let has_private: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(query))
+ .bind(self.config.universe_id)
+ .bind(&selected_ids)
+ .fetch_one(&mut *tx)
+ .await
+ .map_err(|error| session_sql_error("check deletion audience", error))?;
+ if has_private {
+ return Err(SessionStoreError::SessionNotFound {
+ session_id: request.session_id,
+ });
+ }
+ }
+ // A configuration-only clone keeps no history dependency and must
+ // not expose a link to a session that has disappeared.
+ sqlx::query("UPDATE sessions SET source_session_id = NULL WHERE universe_id=$1 AND source_session_id=ANY($2) AND source_seq IS NULL")
+ .bind(self.config.universe_id).bind(&selected_ids).execute(&mut *tx).await
+ .map_err(|error| session_sql_error("detach deleted clone sources", error))?;
+ let now = crate::shared::unix_now_ms();
+ sqlx::query(
+ r#"
+ UPDATE sessions SET deleted_at_ms = $3
+ WHERE universe_id = $1 AND session_id = ANY($2) AND deleted_at_ms IS NULL
+ "#,
+ )
+ .bind(self.config.universe_id)
+ .bind(&selected_ids)
+ .bind(now)
+ .execute(&mut *tx)
+ .await
+ .map_err(|error| session_sql_error("delete session subtree", error))?;
+ tx.commit()
+ .await
+ .map_err(|error| session_sql_error("commit delete session", error))?;
+ let deleted_session_ids = selected_ids
+ .into_iter()
+ .map(SessionId::parse)
+ .collect::, _>>()
+ .map_err(|error| SessionStoreError::Store {
+ message: format!("decode deleted session id: {error}"),
+ })?;
+ Ok(DeleteClosedSessionsResult {
+ target,
+ deleted_session_ids,
+ })
+ }
+}
diff --git a/crates/store-pg/tests/session_lifecycle_live.rs b/crates/store-pg/tests/session_lifecycle_live.rs
index aa84a5b40..6908d5f53 100644
--- a/crates/store-pg/tests/session_lifecycle_live.rs
+++ b/crates/store-pg/tests/session_lifecycle_live.rs
@@ -444,3 +444,223 @@ async fn cleanup_universe(store: &PgStore) {
.await
.expect("clean up test universe");
}
+
+#[tokio::test(flavor = "current_thread")]
+#[ignore = "requires ./dev.sh infra or compatible Postgres env"]
+async fn soft_delete_hides_history_until_explicit_permanent_deletion() {
+ use harness::storage::{DeleteClosedSessions, ReadSessionEventRange, ReadSessionEvents};
+ let store = live_store().await;
+ let root = SessionId::new("deleted-root");
+ let create = CreateSession {
+ session_id: root.clone(),
+ display_name: None,
+ metadata: Default::default(),
+ origin: None,
+ delete_after_close_ms: Some(100),
+ created_at_ms: 1,
+ };
+ store.create_session(create.clone()).await.unwrap();
+ let deletion = DeleteClosedSessions {
+ session_id: root.clone(),
+ cascade: true,
+ due_at_or_before_ms: None,
+ };
+ assert!(matches!(
+ store.delete_closed_sessions(deletion.clone()).await,
+ Err(SessionStoreError::SessionNotClosed { .. })
+ ));
+ assert!(matches!(
+ store.purge_deleted_session(root.as_str()).await,
+ Err(store_pg::PurgeSessionError::NotDeleted { .. })
+ ));
+ store
+ .append(AppendSessionEvents {
+ session_id: root.clone(),
+ expected_head: None,
+ events: vec![
+ lifecycle_event(10, CORE_AGENT_LIFECYCLE_OPENED_EVENT_KIND),
+ lifecycle_event(20, CORE_AGENT_LIFECYCLE_CLOSED_EVENT_KIND),
+ ],
+ })
+ .await
+ .unwrap();
+ let fork = SessionId::new("deleted-fork");
+ store
+ .create_forked_session(CreateForkedSession {
+ session_id: fork.clone(),
+ source_session_id: root.clone(),
+ source_seq: EventSeq::new(2),
+ created_at_ms: 21,
+ })
+ .await
+ .unwrap();
+ let clone_id = SessionId::new("independent-clone");
+ store
+ .create_cloned_session(CreateClonedSession {
+ session_id: clone_id.clone(),
+ source_session_id: root.clone(),
+ created_at_ms: 22,
+ opening_events: vec![],
+ })
+ .await
+ .unwrap();
+ assert!(matches!(
+ store
+ .delete_closed_sessions(DeleteClosedSessions {
+ cascade: false,
+ ..deletion.clone()
+ })
+ .await,
+ Err(SessionStoreError::SessionHasChildren { .. })
+ ));
+ let access = store_pg::PgAccessStore::new(store.pool().clone());
+ assert!(
+ access
+ .share_session(store.config().universe_id, root.as_str())
+ .await
+ .unwrap()
+ );
+ assert!(matches!(
+ store
+ .delete_closed_sessions_with_visibility(deletion.clone(), true)
+ .await,
+ Err(SessionStoreError::SessionNotFound { .. })
+ ));
+ assert!(store.load_session(&root).await.unwrap().is_some());
+ assert!(store.load_session(&fork).await.unwrap().is_some());
+ assert!(
+ access
+ .share_session(store.config().universe_id, fork.as_str())
+ .await
+ .unwrap()
+ );
+ let deleted = store
+ .delete_closed_sessions_with_visibility(deletion.clone(), true)
+ .await
+ .unwrap();
+ assert_eq!(deleted.deleted_session_ids.len(), 2);
+ for id in [&root, &fork] {
+ assert!(store.load_session(id).await.unwrap().is_none());
+ assert!(matches!(
+ store
+ .read_after(ReadSessionEvents {
+ session_id: id.clone(),
+ after: None,
+ limit: 10
+ })
+ .await,
+ Err(SessionStoreError::SessionNotFound { .. })
+ ));
+ assert!(matches!(
+ store
+ .read_range(ReadSessionEventRange {
+ session_id: id.clone(),
+ after: EventSeq::new(2),
+ through: EventSeq::new(2),
+ limit: 10
+ })
+ .await,
+ Err(SessionStoreError::SessionNotFound { .. })
+ ));
+ assert!(
+ store_pg::PgAccessStore::new(store.pool().clone())
+ .session_access(store.config().universe_id, id.as_str())
+ .await
+ .unwrap()
+ .is_none()
+ );
+ assert!(matches!(
+ store
+ .set_session_display_name(id, Some("resurrect".into()))
+ .await,
+ Err(SessionStoreError::SessionNotFound { .. })
+ ));
+ }
+ assert_eq!(
+ store
+ .list_sessions(ListSessions {
+ limit: 10,
+ ..Default::default()
+ })
+ .await
+ .unwrap()
+ .sessions
+ .len(),
+ 1
+ );
+ assert!(
+ store
+ .load_session(&clone_id)
+ .await
+ .unwrap()
+ .unwrap()
+ .source_session_id
+ .is_none()
+ );
+ assert!(
+ store
+ .list_retention_roots_due_for_deletion(u64::MAX / 2, 10)
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ let retained: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM sessions WHERE universe_id=$1 AND deleted_at_ms IS NOT NULL",
+ )
+ .bind(store.config().universe_id)
+ .fetch_one(store.pool())
+ .await
+ .unwrap();
+ assert_eq!(retained, 2);
+ assert!(matches!(
+ store.create_session(create).await,
+ Err(SessionStoreError::SessionAlreadyExists { .. })
+ ));
+ assert!(matches!(
+ store.delete_closed_sessions(deletion).await,
+ Err(SessionStoreError::SessionNotFound { .. })
+ ));
+ let listed = store.list_deleted_sessions(None, 100).await.unwrap();
+ assert_eq!(listed.len(), 2);
+ assert_eq!(
+ store
+ .list_deleted_sessions(Some(&listed[0].0), 100)
+ .await
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ store
+ .purge_deleted_session(root.as_str())
+ .await
+ .unwrap()
+ .len(),
+ 2
+ );
+ assert!(
+ store
+ .purge_deleted_session(root.as_str())
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ assert!(
+ store
+ .list_deleted_sessions(None, 100)
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ let remaining: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM session_events WHERE universe_id=$1 AND session_id=ANY($2)",
+ )
+ .bind(store.config().universe_id)
+ .bind(vec![root.as_str(), fork.as_str()])
+ .fetch_one(store.pool())
+ .await
+ .unwrap();
+ assert_eq!(remaining, 0);
+ assert!(store.load_session(&clone_id).await.unwrap().is_some());
+ cleanup_universe(&store).await;
+}
diff --git a/crates/store-pg/tests/store_pg_live.rs b/crates/store-pg/tests/store_pg_live.rs
index 4b01d250d..07fad9a15 100644
--- a/crates/store-pg/tests/store_pg_live.rs
+++ b/crates/store-pg/tests/store_pg_live.rs
@@ -1284,6 +1284,14 @@ async fn pg_live_sweep_frees_only_unreachable_blobs_after_grace() {
})
.await
.expect("delete doomed session");
+ assert_eq!(
+ candidate_refs(&store, cutoff_ms, &pinned).await,
+ before_delete
+ );
+ store
+ .purge_deleted_session(doomed.as_str())
+ .await
+ .expect("purge doomed session");
let candidates = store
.list_sweep_candidates(cutoff_ms, &pinned, 1024)
.await
@@ -1534,6 +1542,11 @@ async fn pg_live_sweep_follows_fork_trees_and_clone_roots() {
.await
.expect("delete fork tree");
assert_eq!(deleted.deleted_session_ids.len(), 2);
+ assert!(candidate_refs(&store, cutoff_ms, &pinned).await.is_empty());
+ store
+ .purge_deleted_session(root.as_str())
+ .await
+ .expect("purge fork tree");
assert_eq!(
candidate_refs(&store, cutoff_ms, &pinned).await,
vec![fork_only.clone()]
@@ -1546,6 +1559,10 @@ async fn pg_live_sweep_follows_fork_trees_and_clone_roots() {
})
.await
.expect("delete clone");
+ store
+ .purge_deleted_session(clone.as_str())
+ .await
+ .expect("purge clone");
assert_eq!(
candidate_refs(&store, cutoff_ms, &pinned).await,
sorted([&config_blob, &fork_only])
diff --git a/crates/temporal-runtime/src/bots/admission.rs b/crates/temporal-runtime/src/bots/admission.rs
index d9a18b136..b282afade 100644
--- a/crates/temporal-runtime/src/bots/admission.rs
+++ b/crates/temporal-runtime/src/bots/admission.rs
@@ -24,7 +24,7 @@ use bots::{
};
use harness::storage::BlobStore;
use serde_json::Value;
-use temporal_workflow::bots::BotControllerArgs;
+use temporal_workflow::bots::{BotControllerArgs, BotControllerCarry};
use temporalio_client::{UntypedSignal, UntypedWorkflow, WorkflowStartOptions};
use temporalio_common::data_converters::{PayloadConverter, RawValue};
use temporalio_common::protos::coresdk::AsJsonPayloadExt as _;
@@ -219,6 +219,7 @@ impl GatewayAgentApi {
WorkflowStartOptions::new(self.bot_task_queue().to_owned(), workflow_id).build();
self.start_bot_controller(
config,
+ bot.created_at_ms,
options,
bots::BOT_EVENT_SIGNAL,
RawValue::new(vec![payload]),
@@ -231,14 +232,20 @@ impl GatewayAgentApi {
async fn start_bot_controller(
&self,
config: BotControllerConfig,
+ created_at_ms: i64,
options: WorkflowStartOptions,
signal_name: &str,
signal_input: RawValue,
) -> anyhow::Result<()> {
+ let previous = self
+ .store()
+ .previous_bot_session_ids(&config.bot_id, created_at_ms)
+ .await?;
+ let carry = carry_after_previous_sessions(&config.bot_id, previous)?;
let input = RawValue::from_value(
&BotControllerArgs {
config,
- carry: None,
+ carry: Some(carry),
},
&PayloadConverter::default(),
);
@@ -266,6 +273,7 @@ impl GatewayAgentApi {
WorkflowStartOptions::new(self.bot_task_queue().to_owned(), workflow_id).build();
self.start_bot_controller(
config,
+ bot.created_at_ms,
options,
bots::BOT_CONFIG_SIGNAL,
RawValue::new(vec![payload]),
@@ -487,3 +495,69 @@ mod tests {
assert!(input.trigger_id.is_none());
}
}
+
+/// Reusing a bot id starts after its retained session generations.
+fn carry_after_previous_sessions(
+ bot_id: &BotId,
+ ids: Vec,
+) -> anyhow::Result {
+ let main = bots::ids::bot_main_session_id(bot_id, 1);
+ let mut carry = BotControllerCarry::default();
+ for id in ids {
+ let (base, generation) = if id == main {
+ (id.as_str(), 1)
+ } else if let Some(generation) = id.strip_prefix(&format!("{main}-g"))
+ && let Ok(generation) = generation.parse::()
+ {
+ (main.as_str(), generation)
+ } else if id.starts_with(&format!("{main}:")) {
+ let base = bots::ids::routed_session_base(&id);
+ let generation = if base == id {
+ 1
+ } else {
+ id[base.len() + 2..].parse::()?
+ };
+ (base, generation)
+ } else {
+ continue;
+ };
+ let next = generation
+ .checked_add(1)
+ .ok_or_else(|| anyhow::anyhow!("bot session generation exhausted"))?;
+ if base == main {
+ carry.main_generation = carry.main_generation.max(next);
+ } else {
+ let current = carry
+ .session_generations
+ .entry(base.to_owned())
+ .or_insert(1);
+ *current = (*current).max(next);
+ }
+ }
+ Ok(carry)
+}
+
+#[cfg(test)]
+mod retained_session_tests {
+ use super::*;
+ #[test]
+ fn new_bot_incarnation_skips_old_main_and_routed_generations() {
+ let carry = carry_after_previous_sessions(
+ &BotId::new("triage"),
+ vec![
+ "bot:v1:triage".into(),
+ "bot:v1:triage-g4".into(),
+ "bot:v1:triage:k-mail-g3".into(),
+ "bot:v1:triage-other".into(),
+ ],
+ )
+ .unwrap();
+ assert_eq!(carry.main_generation, 5);
+ assert_eq!(
+ carry.session_generations.get("bot:v1:triage:k-mail"),
+ Some(&4)
+ );
+ assert_eq!(carry.session_generations.len(), 1);
+ assert!(!carry.session_ready);
+ }
+}
diff --git a/crates/temporal-runtime/src/bots/sessions.rs b/crates/temporal-runtime/src/bots/sessions.rs
index f2b30f802..ac364dd55 100644
--- a/crates/temporal-runtime/src/bots/sessions.rs
+++ b/crates/temporal-runtime/src/bots/sessions.rs
@@ -469,7 +469,7 @@ pub async fn ensure_session(
profile: Some(ProfileSource::Inline {
profile: Box::new(resolved.clone()),
}),
- delete_after_close_ms: None,
+ delete_after_close_ms: Some(None),
workflow_tools: ManagedSessionWorkflowToolsInput {
version: MANAGED_TOOLS_VERSION,
lifecycle_controller: Some(controller),
diff --git a/crates/temporal-runtime/src/gateway/deployment.rs b/crates/temporal-runtime/src/gateway/deployment.rs
index 4f904d7b3..eda0a394c 100644
--- a/crates/temporal-runtime/src/gateway/deployment.rs
+++ b/crates/temporal-runtime/src/gateway/deployment.rs
@@ -138,6 +138,72 @@ impl GatewayDeploymentApi {
#[async_trait]
impl DeploymentApiService for GatewayDeploymentApi {
+ async fn list_deleted_sessions(
+ &self,
+ params: api::DeploymentDeletedSessionsListParams,
+ ) -> Result, AgentApiError> {
+ self.admitted(api::METHOD_DEPLOYMENT_SESSIONS_DELETED_LIST, async {
+ let universe_id = parse_universe_id(¶ms.universe_id)?;
+ self.require_universe(universe_id).await?;
+ let rows = self
+ .runtime
+ .stores()
+ .store_for(universe_id)
+ .list_deleted_sessions(params.after.as_deref(), 101)
+ .await
+ .map_err(|error| AgentApiError::internal(error.to_string()))?;
+ let next_after = (rows.len() > 100).then(|| rows[99].0.clone());
+ let sessions = rows
+ .into_iter()
+ .take(100)
+ .map(
+ |(session_id, display_name, deleted_at_ms)| api::DeletedSessionView {
+ session_id,
+ display_name,
+ deleted_at_ms,
+ },
+ )
+ .collect();
+ Ok(AgentApiOutcome::new(
+ api::DeploymentDeletedSessionsListResponse {
+ sessions,
+ next_after,
+ },
+ ))
+ })
+ .await
+ }
+
+ async fn purge_session(
+ &self,
+ params: api::DeploymentSessionPurgeParams,
+ ) -> Result, AgentApiError> {
+ self.admitted(api::METHOD_DEPLOYMENT_SESSIONS_PURGE, async {
+ let universe_id = parse_universe_id(¶ms.universe_id)?;
+ self.require_universe(universe_id).await?;
+ SessionId::try_new(¶ms.session_id)
+ .map_err(|error| AgentApiError::invalid_request(error.to_string()))?;
+ let deleted_session_ids = self
+ .runtime
+ .stores()
+ .store_for(universe_id)
+ .purge_deleted_session(¶ms.session_id)
+ .await
+ .map_err(|error| match error {
+ store_pg::PurgeSessionError::NotDeleted { .. } => {
+ AgentApiError::rejected(error.to_string())
+ }
+ store_pg::PurgeSessionError::Postgres(_) => {
+ AgentApiError::internal(error.to_string())
+ }
+ })?;
+ Ok(AgentApiOutcome::new(api::DeploymentSessionPurgeResponse {
+ deleted_session_ids,
+ }))
+ })
+ .await
+ }
+
async fn list_deployment_provider_bindings(
&self,
params: api::DeploymentUniverseReadParams,
diff --git a/crates/temporal-runtime/src/gateway/request_context.rs b/crates/temporal-runtime/src/gateway/request_context.rs
index 2057c50c5..4349766e8 100644
--- a/crates/temporal-runtime/src/gateway/request_context.rs
+++ b/crates/temporal-runtime/src/gateway/request_context.rs
@@ -213,6 +213,19 @@ mod tests {
);
}
+ #[test]
+ fn ordinary_session_keys_cannot_delete_or_schedule_deletion() {
+ let ordinary = keyed(&[MethodGroup::Session], None);
+ assert!(ordinary.permits("session/close"));
+ assert!(!ordinary.permits("session/delete"));
+ assert!(!ordinary.permits("session/retention/put"));
+ assert!(!ordinary.permits("deployment/sessions/purge"));
+ let destructive = keyed(&[MethodGroup::SessionDelete], None);
+ assert!(destructive.permits("session/delete"));
+ assert!(destructive.permits("session/retention/put"));
+ assert!(!destructive.permits("deployment/sessions/purge"));
+ }
+
#[test]
fn caller_access_reports_what_permits_allows() {
let connector = keyed(&[MethodGroup::ChannelsInbound, MethodGroup::BlobsPut], None);
diff --git a/crates/temporal-runtime/src/gateway/service/bots_api.rs b/crates/temporal-runtime/src/gateway/service/bots_api.rs
index 75ed9b7f7..f0b70e13c 100644
--- a/crates/temporal-runtime/src/gateway/service/bots_api.rs
+++ b/crates/temporal-runtime/src/gateway/service/bots_api.rs
@@ -538,21 +538,7 @@ impl GatewayAgentApi {
self.close_bot_record(bot_id).await?;
}
self.wait_for_bot_controller_closed(bot_id).await?;
- let bot = self.read_bot_record(bot_id).await?;
- let mut deleted_sessions = Vec::new();
- for session_id in &bot.closed_sessions {
- match self
- .delete_session(SessionDeleteParams {
- session_id: session_id.clone(),
- cascade: true,
- })
- .await
- {
- Ok(_) => deleted_sessions.push(session_id.clone()),
- Err(error) if error.kind == AgentApiErrorKind::NotFound => {}
- Err(error) => return Err(error),
- }
- }
+ // Bot configuration removal does not remove retained session history.
let store = self.store();
for trigger in store
.list_bot_triggers(bot_id)
@@ -566,7 +552,7 @@ impl GatewayAgentApi {
}
}
let bot = store.delete_bot(bot_id).await.map_err(map_bot_error)?;
- Ok((bot, deleted_sessions))
+ Ok((bot, Vec::new()))
}
pub(crate) async fn bot_state_view(
diff --git a/crates/temporal-runtime/src/gateway/service/mod.rs b/crates/temporal-runtime/src/gateway/service/mod.rs
index 994df3dfc..b29d25fdd 100644
--- a/crates/temporal-runtime/src/gateway/service/mod.rs
+++ b/crates/temporal-runtime/src/gateway/service/mod.rs
@@ -2680,6 +2680,7 @@ impl AgentApiService for GatewayAgentApi {
due_at_or_before_ms: None,
},
crate::session_deletion::SessionDeletionCause::Manual,
+ params.shared_only,
)
.await
.map_err(map_session_store_error)?;
diff --git a/crates/temporal-runtime/src/gateway/service/session_lifecycle.rs b/crates/temporal-runtime/src/gateway/service/session_lifecycle.rs
index 9378ebaf8..fb3a886e8 100644
--- a/crates/temporal-runtime/src/gateway/service/session_lifecycle.rs
+++ b/crates/temporal-runtime/src/gateway/service/session_lifecycle.rs
@@ -307,6 +307,16 @@ impl GatewayAgentApi {
operation_timeout: self.operation_timeout,
poll_interval: self.poll_interval,
};
+ if self
+ .store
+ .session_is_deleted(session_id.as_str())
+ .await
+ .map_err(map_session_store_error)?
+ {
+ return Err(AgentApiError::not_found(format!(
+ "session not found: {session_id}"
+ )));
+ }
// Recover the original intent before resolving a mutable named profile.
if client_supplied_id
&& let Some(loaded) = lifecycle
@@ -333,6 +343,12 @@ impl GatewayAgentApi {
delete_after_close_ms,
);
validate_delete_after_close_ms(effective_delete_after_close_ms)?;
+ if effective_delete_after_close_ms.is_some()
+ && self.current_controller().is_none()
+ && !self.caller()?.permits(METHOD_SESSION_RETENTION_PUT)
+ {
+ return Err(AgentApiError::forbidden());
+ }
let start_config = Self::merge_profile_start_config(
resolved_profile
.as_ref()
diff --git a/crates/temporal-runtime/src/session_deletion.rs b/crates/temporal-runtime/src/session_deletion.rs
index 9701d209c..54327672d 100644
--- a/crates/temporal-runtime/src/session_deletion.rs
+++ b/crates/temporal-runtime/src/session_deletion.rs
@@ -1,6 +1,4 @@
-use harness::storage::{
- DeleteClosedSessions, DeleteClosedSessionsResult, SessionStore, SessionStoreError,
-};
+use harness::storage::{DeleteClosedSessions, DeleteClosedSessionsResult, SessionStoreError};
use store_pg::PgStore;
#[derive(Clone, Copy, Debug)]
@@ -23,10 +21,13 @@ pub(crate) async fn delete_session_subtree(
store: &PgStore,
request: DeleteClosedSessions,
cause: SessionDeletionCause,
+ shared_only: bool,
) -> Result {
let requested_session_id = request.session_id.clone();
let cascade = request.cascade;
- let deleted = SessionStore::delete_closed_sessions(store, request).await?;
+ let deleted = store
+ .delete_closed_sessions_with_visibility(request, shared_only)
+ .await?;
tracing::info!(
target: "temporal_runtime",
requested_session_id = %requested_session_id,
diff --git a/crates/temporal-runtime/src/worker/reaper.rs b/crates/temporal-runtime/src/worker/reaper.rs
index 208f09a23..3022c2108 100644
--- a/crates/temporal-runtime/src/worker/reaper.rs
+++ b/crates/temporal-runtime/src/worker/reaper.rs
@@ -513,6 +513,7 @@ impl SessionRetentionReaper {
due_at_or_before_ms: Some(now_ms),
},
SessionDeletionCause::Retention,
+ false,
)
.await;
match result {
diff --git a/crates/temporal-runtime/tests/bots_live.rs b/crates/temporal-runtime/tests/bots_live.rs
index f827a4e26..fe24ebec8 100644
--- a/crates/temporal-runtime/tests/bots_live.rs
+++ b/crates/temporal-runtime/tests/bots_live.rs
@@ -822,7 +822,7 @@ async fn bots_live_schedule_trigger_reconciles_temporal_schedule() -> anyhow::Re
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "requires the local Temporal + PostgreSQL stack (source scripts/dev/env.sh)"]
-async fn bots_live_close_and_delete_tear_down() -> anyhow::Result<()> {
+async fn bots_live_close_and_delete_preserve_history_and_recreate() -> anyhow::Result<()> {
run_bots_live(Llm::Fake, |api, _client| async move {
let profile_id = create_profile(&api, "You are a live-test bot.").await?;
let bot_id = create_bot(&api, &profile_id, |_| {}, Vec::new()).await?;
@@ -896,7 +896,7 @@ async fn bots_live_close_and_delete_tear_down() -> anyhow::Result<()> {
})
.await?
.result;
- assert!(deleted.deleted_sessions.contains(&main_session));
+ assert!(deleted.deleted_sessions.is_empty());
assert!(
api.read_bot(BotReadParams {
bot_id: bot_id.clone()
@@ -904,14 +904,40 @@ async fn bots_live_close_and_delete_tear_down() -> anyhow::Result<()> {
.await
.is_err()
);
- assert!(
- api.read_session(SessionReadParams {
- session_id: main_session,
+ let retained = api
+ .read_session(SessionReadParams {
+ session_id: main_session.clone(),
run_limit: None,
})
- .await
- .is_err()
- );
+ .await?
+ .result
+ .session;
+ assert_eq!(retained.status, SessionStatus::Closed);
+ assert_eq!(retained.access.visibility, api::Visibility::Universe);
+ api.create_bot(BotCreateParams {
+ bot: BotInput {
+ bot_id: bot_id.clone(),
+ document: bot_document(&profile_id),
+ },
+ triggers: vec![],
+ })
+ .await?;
+ api.admit_bot_event(BotEventAdmitParams {
+ bot_id: bot_id.clone(),
+ event: manual_event("new incarnation"),
+ })
+ .await?;
+ wait_for_outcomes(&api, &bot_id, 1).await?;
+ let successor = api
+ .read_session(SessionReadParams {
+ session_id: bot_main_session_id(&bot_id, 2),
+ run_limit: None,
+ })
+ .await?
+ .result
+ .session;
+ assert_ne!(successor.id, retained.id);
+ api.delete_bot(BotDeleteParams { bot_id }).await?;
Ok(())
})
.await
diff --git a/crates/temporal-runtime/tests/profiles_live.rs b/crates/temporal-runtime/tests/profiles_live.rs
index a52610405..8d1cb9756 100644
--- a/crates/temporal-runtime/tests/profiles_live.rs
+++ b/crates/temporal-runtime/tests/profiles_live.rs
@@ -197,6 +197,7 @@ async fn run_profile_environment_selection_live_client(
api::EnvironmentLifecycleStatusView::Ready
);
api.delete_session(api::SessionDeleteParams {
+ shared_only: false,
session_id: session_id.to_string(),
cascade: false,
})
diff --git a/crates/temporal-runtime/tests/sessions_live.rs b/crates/temporal-runtime/tests/sessions_live.rs
index dae364774..541773316 100644
--- a/crates/temporal-runtime/tests/sessions_live.rs
+++ b/crates/temporal-runtime/tests/sessions_live.rs
@@ -611,6 +611,7 @@ async fn run_checkpoint_and_bounded_reads_live_client(
.await?;
wait_for_session_status(&api, &session_id, SessionStatus::Closed).await?;
api.delete_session(SessionDeleteParams {
+ shared_only: false,
session_id: session_id.as_str().to_owned(),
cascade: false,
})
@@ -947,6 +948,7 @@ async fn run_lifecycle_delete_live_client(
let delete_open = api
.delete_session(SessionDeleteParams {
+ shared_only: false,
session_id: session_id.as_str().to_owned(),
cascade: false,
})
@@ -974,6 +976,7 @@ async fn run_lifecycle_delete_live_client(
let deleted = api
.delete_session(SessionDeleteParams {
+ shared_only: false,
session_id: session_id.as_str().to_owned(),
cascade: false,
})
diff --git a/docs/roadmap/p190-session-deletion-and-closing.md b/docs/roadmap/p190-session-deletion-and-closing.md
new file mode 100644
index 000000000..1e8370e14
--- /dev/null
+++ b/docs/roadmap/p190-session-deletion-and-closing.md
@@ -0,0 +1,105 @@
+# Session closing, deletion, and audit
+
+Status: implemented and validated.
+
+## Policy
+
+Closing ends execution and retains readable history. Soft deletion makes a closed
+session disappear from every ordinary universe interface, including admin views.
+Only Platform administration can inspect deleted-session metadata and permanently
+remove the retained session records.
+
+| Role | Close | Soft delete | Permanently delete |
+| --- | --- | --- | --- |
+| Viewer | None | None | None |
+| Contributor | Own unshared sessions | None | None |
+| Operator | Own sessions and universe-shared sessions | Shared sessions | None |
+| Universe admin | All sessions in their universe | All sessions in their universe | None |
+| Platform admin | All sessions | All sessions | Already soft-deleted sessions |
+
+Ownership and visibility use the existing audience root, including delegated
+sessions. Contributors cannot close shared sessions, even their own. Operators
+cannot delete their own private sessions. Cancelling a run remains separate from
+closing a session. Force-close uses the same permission as close. Existing
+controller rules still apply to managed sessions.
+
+## Storage and deletion
+
+`session/delete` sets `sessions.deleted_at_ms`. There are no tombstone or runtime
+audit tables. Every selected session must already be closed. Existing leaf and
+explicit cascade behavior remains: history forks and delegated descendants are
+included; configuration-only clones remain independent. An atomic shared-only
+guard prevents operator cascades from deleting private forks.
+
+Normal reads, lists, access lookups, mutations, event reads, and source-based
+creation exclude deleted sessions. Universe admins get no trash view or
+include-deleted switch. Retention deletion uses this same soft-delete operation;
+its scheduling remains admin-only. Retained events, checkpoints and CAS roots
+remain intact until explicit permanent deletion. Soft-deleted rows prevent reuse
+of their session IDs while retained.
+
+Permanent deletion is a deployment-scoped operation, exposed only to platform
+admins through the Universes administration page. The admin selects a deleted
+session and confirms removal of it and its deleted history subtree. The operation
+rejects any selected session that has not been soft-deleted. Repeating it after
+successful removal returns no affected IDs. There is no automatic purge, grace
+period, tombstone, restore API or restore UI.
+
+Deleting session rows cascades to their event log, checkpoints and blob roots.
+Shared blobs stay protected by their remaining references; unreferenced blobs
+follow ordinary garbage collection. This does not delete attached workspaces,
+environments, external exports, backups or Temporal history. Direct blob access
+keeps its existing universe-level boundary. Session deletion is not a guarantee
+of erasing every copy of the content.
+
+Deleting a bot closes its managed sessions and retains their history. Recreating
+a bot starts fresh session generations beyond any retained generations.
+
+## Authorization and API keys
+
+Platform enforces the role and audience rules on the server, including direct
+HTTP requests, and mirrors them in individual and bulk UI controls. The runtime
+continues to enforce service-key scopes and method groups, not human memberships.
+
+`session/delete` and `session/retention/put` require the explicit `session/delete`
+key group. Existing ordinary `session` keys do not silently acquire deletion
+access. Administrators deploying this change must provision that capability on
+service keys that need it. Deleted-session administration requires a deployment
+key with `deployment/sessions`; Platform independently requires platform admin.
+
+Deletion retention is admin-only even though operators can immediately delete
+shared closed sessions. Non-admin creation overrides profile deletion schedules
+with no deletion schedule, and rejects an explicit deletion schedule. Bots also
+avoid inheriting profile deletion schedules. This prevents configuration from
+becoming an indirect way to delete private sessions.
+
+## Platform audit
+
+For sessions, Platform audits only successful permanent deletion in its existing
+`identity_audit` table. Records contain the actor, universe, target session,
+action, timestamp and every removed session ID. They survive deletion of session
+data. A separate Platform admin Audit log page displays these events alongside
+access changes. No second audit table or runtime audit API is introduced. Session creation, sharing, close, force-close, retention-policy changes
+and soft deletion do not create audit records. Existing access/security auditing
+remains unchanged.
+
+Audit is scoped to requests through Platform. Direct runtime API calls and
+background retention work do not create Platform audit entries. Runtime mutation
+and Platform audit insertion use separate databases: a mutation may succeed even
+if the subsequent audit write fails. There is no cross-database transaction,
+outbox or audit bridge in this implementation. Failed mutations are not recorded
+as successful actions; empty purge retries do not duplicate successful purge logs.
+
+## Validation and progress
+
+- [x] Define close, soft-delete and permanent-delete authority.
+- [x] Implement the soft-delete flag and hidden-session storage behavior.
+- [x] Preserve bot histories and protect retention admission paths.
+- [x] Reuse Platform audit and add platform-admin inspection and explicit purge.
+- [x] Align individual, bulk and demo lifecycle controls.
+- [x] Complete regression coverage, regenerated contracts and component checks.
+
+Validation passed: workspace Clippy with warnings denied; API, PostgreSQL-store
+and runtime unit tests; local PostgreSQL lifecycle and CAS-retention tests; the
+serialized live bot history/recreation test; full backend, web, SDK and Configurator
+tests; TypeScript checks; production web/demo builds; and release metadata checks.
diff --git a/platform/backend/src/api.ts b/platform/backend/src/api.ts
index 4b991776c..8b29a1b60 100644
--- a/platform/backend/src/api.ts
+++ b/platform/backend/src/api.ts
@@ -1,3 +1,4 @@
+import { sessionAdminRoutes } from "./routes/session-admin.js";
import { withGateway } from "./routes/gateway.js";
import { Hono } from "hono";
import { and, desc, eq, or } from "drizzle-orm";
@@ -94,6 +95,7 @@ export function buildApp(ctx: AppContext) {
api.route("/universes", gatewayRoutes(ctx));
api.route("/universes", botRoutes(ctx));
api.route("/universes", channelUniverseRoutes(ctx));
+ api.route("/admin", sessionAdminRoutes(ctx));
api.route("/admin", environmentDeploymentRoutes(ctx));
api.route("/admin", apiKeyAdminRoutes(ctx));
api.route("/channel-accounts", channelAccountAdminRoutes(ctx));
diff --git a/platform/backend/src/auth.integration.test.ts b/platform/backend/src/auth.integration.test.ts
index 107764d2f..3bd2b3f4a 100644
--- a/platform/backend/src/auth.integration.test.ts
+++ b/platform/backend/src/auth.integration.test.ts
@@ -248,6 +248,7 @@ it("keeps universe roles local, enforces their edits on existing sessions, and r
expect((await fixture.request(`${base}/members/${member.id}`, undefined, adminCookie, "DELETE")).status).toBe(200);
expect(await (await fixture.request("/api/v1/universes", undefined, (await login()).cookie)).json()).toEqual([]);
expect((await fixture.request("/api/v1/admin/audit", undefined, cookie)).status).toBe(403);
+ expect((await fixture.request("/api/v1/admin/universes/u/sessions/s/purge", {}, cookie, "POST")).status).toBe(403);
await fixture.db.delete(schema.user).where(eq(schema.user.id, company.id));
const events = await (await fixture.request("/api/v1/admin/audit", undefined, adminCookie)).json() as Array<{ targetId: string; action: string }>;
expect(events.filter((event) => event.targetId === company.id).map((event) => event.action)).toEqual(expect.arrayContaining(["member.add", "member.role", "member.remove"]));
diff --git a/platform/backend/src/routes/gateway.ts b/platform/backend/src/routes/gateway.ts
index 86a435a48..67e1dea7f 100644
--- a/platform/backend/src/routes/gateway.ts
+++ b/platform/backend/src/routes/gateway.ts
@@ -533,7 +533,7 @@ export function gatewayRoutes(ctx: AppContext) {
});
});
- /// Deletion removes retained history and is accepted by Lightspeed only
+ /// Soft deletion hides retained history and is accepted by Lightspeed only
/// after the selected sessions are closed. Non-cascade deletion requires a
/// leaf; cascade includes history forks and delegated children.
app.delete("/:id/sessions/:sessionId", async (c) => {
diff --git a/platform/backend/src/routes/method-roles.ts b/platform/backend/src/routes/method-roles.ts
index d1f27e2c9..4b5729d81 100644
--- a/platform/backend/src/routes/method-roles.ts
+++ b/platform/backend/src/routes/method-roles.ts
@@ -92,7 +92,7 @@ export const METHOD_ROLES: Readonly> = {
"session/context/compact": "contributor",
"session/context/remove": "contributor",
"session/context/replace": "contributor",
- "session/delete": "admin",
+ "session/delete": "operator",
"session/environments/activate": "operator",
"session/environments/deactivate": "operator",
"session/events/read": "viewer",
@@ -171,7 +171,7 @@ export const UNMEMBERED_METHODS: ReadonlySet = new Set([
"deployment/environment-providers/put",
"deployment/environment-providers/read",
"deployment/environments/adopt",
- "deployment/sessions/audit/list",
+ "deployment/sessions/deleted/list",
"deployment/sessions/purge",
"deployment/universes/create",
"deployment/universes/delete",
diff --git a/platform/backend/src/routes/session-admin.ts b/platform/backend/src/routes/session-admin.ts
new file mode 100644
index 000000000..90c20739f
--- /dev/null
+++ b/platform/backend/src/routes/session-admin.ts
@@ -0,0 +1,38 @@
+import { Hono } from "hono";
+import { eq } from "drizzle-orm";
+import { schema } from "@lightspeed-ai/platform-db";
+import type { ApiVariables, AppContext } from "../context.js";
+import { isPlatformAdmin } from "../context.js";
+import { auditIdentity } from "../identity-audit.js";
+import { deploymentClientFor, withGateway } from "./gateway.js";
+
+/** Deleted sessions are available only through Platform administration. */
+export function sessionAdminRoutes(ctx: AppContext) {
+ const app = new Hono<{ Variables: ApiVariables }>();
+ app.use("*", async (c, next) => {
+ if (!isPlatformAdmin(c.get("session"))) return c.json({ error: "platform admin required" }, 403);
+ await next();
+ });
+ app.get("/universes/:id/deleted-sessions", (c) => withGateway(c, async () => {
+ const [universe] = await ctx.db.select().from(schema.universes).where(eq(schema.universes.id, c.req.param("id"))).limit(1);
+ if (!universe) return c.json({ error: "not found" }, 404);
+ const result = await deploymentClientFor(ctx, universe.gatewayUrl).call("deployment/sessions/deleted/list", {
+ universeId: universe.lightspeedUniverseId, after: c.req.query("after"),
+ });
+ return c.json(result.result);
+ }));
+ app.post("/universes/:id/sessions/:sessionId/purge", (c) => withGateway(c, async () => {
+ const [universe] = await ctx.db.select().from(schema.universes).where(eq(schema.universes.id, c.req.param("id"))).limit(1);
+ if (!universe) return c.json({ error: "not found" }, 404);
+ const result = await deploymentClientFor(ctx, universe.gatewayUrl).call("deployment/sessions/purge", {
+ universeId: universe.lightspeedUniverseId, sessionId: c.req.param("sessionId"),
+ });
+ if (result.result.deletedSessionIds.length) await auditIdentity(ctx.db, {
+ actorId: c.get("session").user.id, universeId: universe.id,
+ targetId: c.req.param("sessionId"), action: "session.purge",
+ details: { deletedSessionIds: JSON.stringify(result.result.deletedSessionIds) },
+ });
+ return c.json(result.result);
+ }));
+ return app;
+}
diff --git a/platform/backend/src/routes/session-lifecycle.test.ts b/platform/backend/src/routes/session-lifecycle.test.ts
new file mode 100644
index 000000000..c5ff0a57a
--- /dev/null
+++ b/platform/backend/src/routes/session-lifecycle.test.ts
@@ -0,0 +1,82 @@
+import { Hono } from "hono";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { ApiVariables, AppContext } from "../context.js";
+import { gatewayRoutes } from "./gateway.js";
+import { sessionAdminRoutes } from "./session-admin.js";
+
+const auth = vi.hoisted(() => ({ role: "admin", platformAdmin: false }));
+vi.mock("./universes.js", () => ({ universeForSession: vi.fn(async () => ({
+ universe: { id: "universe", lightspeedUniverseId: "runtime-universe", gatewayUrl: null },
+ slug: "test", role: auth.role, member: { userId: "member", role: auth.role },
+})) }));
+beforeEach(() => { auth.role = "admin"; auth.platformAdmin = false; });
+afterEach(() => vi.unstubAllGlobals());
+
+function fixture({ failMutation = false, purgedIds = ["s1", "child"] }: { failMutation?: boolean; purgedIds?: string[] } = {}) {
+ const audits = vi.fn(async () => undefined);
+ const rpcCalls: string[] = [];
+ vi.stubGlobal("fetch", vi.fn(async (_url: unknown, init: RequestInit) => {
+ const rpc = JSON.parse(String(init.body));
+ rpcCalls.push(rpc.method);
+ if (failMutation && rpc.method !== "session/read") {
+ return Response.json({ id: rpc.id, error: { code: -32009, message: "failed", data: { kind: "conflict" } } });
+ }
+ const result = rpc.method === "deployment/sessions/purge" ? { deletedSessionIds: purgedIds }
+ : rpc.method === "deployment/sessions/deleted/list" ? { sessions: [], nextAfter: null }
+ : { session: { id: "s1", access: { visibility: "universe" } }, deletedSessionCount: 2 };
+ return Response.json({ id: rpc.id, result: { result, notifications: [] } });
+ }));
+ const db = { insert: () => ({ values: audits }), select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id: "universe", lightspeedUniverseId: "runtime-universe" }] }) }) }) };
+ const ctx = { db, env: { lightspeedApiUrl: "https://engine.example/rpc", lightspeedApiKey: "lsk_fixture" } } as unknown as AppContext;
+ const app = new Hono<{ Variables: ApiVariables }>();
+ app.use("*", async (c, next) => {
+ c.set("session", { user: { id: "member", role: auth.platformAdmin ? "admin" : "user" } } as ApiVariables["session"]);
+ await next();
+ });
+ app.route("/universes", gatewayRoutes(ctx));
+ app.route("/admin", sessionAdminRoutes(ctx));
+ const call = (path: string, method = "POST", body: unknown = {}) => app.request(path, { method,
+ headers: { "content-type": "application/json" }, ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
+ });
+ return { call, audits, rpcCalls };
+}
+
+it.each([false, true])("closes with force=%s without writing an audit record", async (force) => {
+ const f = fixture();
+ expect((await f.call("/universes/universe/sessions/s1/close", "POST", { force })).status).toBe(200);
+ expect(f.rpcCalls).toContain("session/close");
+ expect(f.audits).not.toHaveBeenCalled();
+});
+it("soft deletes shared sessions without writing an audit record", async () => {
+ auth.role = "operator";
+ const f = fixture();
+ expect((await f.call("/universes/universe/sessions/s1?cascade=true", "DELETE")).status).toBe(200);
+ expect(f.rpcCalls).toContain("session/delete");
+ expect(f.audits).not.toHaveBeenCalled();
+});
+it("does not record successful purge when the runtime rejects it", async () => {
+ auth.platformAdmin = true;
+ const f = fixture({ failMutation: true });
+ expect((await f.call("/admin/universes/universe/sessions/s1/purge")).status).not.toBe(200);
+ expect(f.audits).not.toHaveBeenCalled();
+});
+it("denies universe admins access to permanent deletion and deleted-session lists", async () => {
+ const f = fixture();
+ expect((await f.call("/admin/universes/universe/sessions/s1/purge")).status).toBe(403);
+ expect((await f.call("/admin/universes/universe/deleted-sessions", "GET")).status).toBe(403);
+ expect(f.rpcCalls).toEqual([]);
+ expect(f.audits).not.toHaveBeenCalled();
+});
+it("allows platform admins to list and permanently delete, auditing the affected IDs", async () => {
+ auth.platformAdmin = true;
+ const f = fixture();
+ expect((await f.call("/admin/universes/universe/deleted-sessions", "GET")).status).toBe(200);
+ expect((await f.call("/admin/universes/universe/sessions/s1/purge")).status).toBe(200);
+ expect(f.audits).toHaveBeenCalledWith(expect.objectContaining({ actorId: "member", universeId: "universe", targetId: "s1", action: "session.purge", outcome: "success", details: { deletedSessionIds: '["s1","child"]' } }));
+});
+it("does not duplicate purge audit when the runtime reports an already-removed session", async () => {
+ auth.platformAdmin = true;
+ const f = fixture({ purgedIds: [] });
+ expect((await f.call("/admin/universes/universe/sessions/s1/purge")).status).toBe(200);
+ expect(f.audits).not.toHaveBeenCalled();
+});
diff --git a/platform/backend/src/runtime-client.test.ts b/platform/backend/src/runtime-client.test.ts
index 35687813e..5091788a8 100644
--- a/platform/backend/src/runtime-client.test.ts
+++ b/platform/backend/src/runtime-client.test.ts
@@ -221,13 +221,19 @@ describe("session lifecycle permissions", () => {
}
});
}
+ for (const shared of [true, false]) it(`${role} soft deletes shared=${shared} only when permitted`, async () => {
+ const calls = core({ s: { visibility: shared ? "universe" : "restricted", createdBy: { kind: "actor", id: "alice" } } });
+ const error = await refusal(as(role).call("session/delete", { sessionId: "s", sharedOnly: false }));
+ expect(error === null).toBe(role === "admin" || (role === "operator" && shared));
+ if (role === "operator" && shared) expect(calls.at(-1)?.params.sharedOnly).toBe(true);
+ });
it(`${role} deletes and configures retention only as admin`, async () => {
const calls = core({ s: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } });
for (const method of ["session/delete", "session/retention/put"] as const) {
const error = await refusal(as(role).call(method, { sessionId: "s", deleteAfterCloseMs: 1 } as never));
expect(error === null).toBe(role === "admin");
}
- if (role !== "admin") expect(calls).toHaveLength(0);
+ if (role !== "admin") expect(calls.every((call) => call.method === "session/read")).toBe(true);
});
}
it("contributors still cancel shared runs", async () => {
diff --git a/platform/backend/src/runtime-client.ts b/platform/backend/src/runtime-client.ts
index cf1a88508..5fd1a52ad 100644
--- a/platform/backend/src/runtime-client.ts
+++ b/platform/backend/src/runtime-client.ts
@@ -6,7 +6,7 @@ import {
type MethodParams,
type MethodResult,
} from "@lightspeed-ai/sdk";
-import { canCloseSession, roleAtLeast, type UniverseRole } from "@lightspeed-ai/platform-shared";
+import { canCloseSession, canDeleteSession, roleAtLeast, type UniverseRole } from "@lightspeed-ai/platform-shared";
import type { ServerEnv } from "./env.js";
import { METHOD_ROLES, SESSION_TARGET_METHODS } from "./routes/method-roles.js";
@@ -33,8 +33,8 @@ const CREATION_METHODS: ReadonlySet = new Set(["session/start", "session
/// Deployment methods, with the Platform's deployment key and no universe or
/// actor. Callers check that the user is a platform admin.
-export function deploymentClient(env: ServerEnv, endpoint?: string | null, actorId?: string): LightspeedClient {
- return new LightspeedClient(clientOptions(env, endpoint, actorId ? { "x-lightspeed-actor": actorId } : {}));
+export function deploymentClient(env: ServerEnv, endpoint?: string | null): LightspeedClient {
+ return new LightspeedClient(clientOptions(env, endpoint, {}));
}
/// Calls as a universe key someone handed the Platform, to learn which key a
@@ -95,6 +95,9 @@ class MemberClient extends LightspeedClient {
}
}
const admin = this.member.role === "admin";
+ if (!admin && method === "session/delete") {
+ params = { ...params, sharedOnly: true } as MethodParams;
+ }
if (!admin && CREATION_METHODS.has(method)) {
const start = params as { deleteAfterCloseMs?: number | null };
if (start.deleteAfterCloseMs != null) throw new GateRefusal(403, "admin role required for deletion retention");
@@ -125,6 +128,9 @@ class MemberClient extends LightspeedClient {
const creator = access.createdBy?.kind === "actor" && access.createdBy.id === this.member.userId;
const visible = CREATOR_METHODS.has(method) ? creator : creator || access.visibility === "universe";
if (!visible) throw new GateRefusal(404, "session not found");
+ if (method === "session/delete" && !canDeleteSession(this.member.role, access.visibility === "universe")) {
+ throw new GateRefusal(403, "operators may only delete shared sessions");
+ }
if (method === "session/close" && !canCloseSession(this.member.role, creator, access.visibility === "universe")) {
throw new GateRefusal(403, "contributors may close only their own unshared sessions");
}
diff --git a/platform/configurator-mcp/src/generated/tools.ts b/platform/configurator-mcp/src/generated/tools.ts
index cf1842116..195b90af7 100644
--- a/platform/configurator-mcp/src/generated/tools.ts
+++ b/platform/configurator-mcp/src/generated/tools.ts
@@ -2236,7 +2236,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
"method": "session/delete",
"group": "session/delete",
"summary": "Delete closed sessions",
- "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.",
+ "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
"paramsType": "SessionDeleteParams",
"resultType": "AgentApiOutcome",
"inputSchema": {
@@ -2248,6 +2248,10 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
},
"sessionId": {
"type": "string"
+ },
+ "sharedOnly": {
+ "description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
+ "type": "boolean"
}
},
"required": [
diff --git a/platform/shared/src/index.ts b/platform/shared/src/index.ts
index 97cd44ef3..6d7ac1866 100644
--- a/platform/shared/src/index.ts
+++ b/platform/shared/src/index.ts
@@ -170,3 +170,14 @@ export {
type MessageAttachment,
type MessageRunOptions,
} from "./messages.js";
+
+/** Closing shared work is an operator responsibility, even for its creator. */
+export function canCloseSession(role: UniverseRole | null | undefined, creator: boolean, shared: boolean): boolean {
+ return role === "admin" || (role === "operator" && (creator || shared)) ||
+ (role === "contributor" && creator && !shared);
+}
+
+/** Operators may remove shared sessions; private sessions require an admin. */
+export function canDeleteSession(role: UniverseRole | null | undefined, shared: boolean): boolean {
+ return role === "admin" || (role === "operator" && shared);
+}
diff --git a/platform/web/src/App.tsx b/platform/web/src/App.tsx
index 2a0de2a60..ed9d116c7 100644
--- a/platform/web/src/App.tsx
+++ b/platform/web/src/App.tsx
@@ -12,6 +12,7 @@ import { AccountPage } from "@/pages/AccountPage";
import { ApiKeysPage } from "@/pages/ApiKeysPage";
import { AdminApiKeysPage } from "@/pages/AdminApiKeysPage";
import { AdminUniversesPage } from "@/pages/AdminUniversesPage";
+import { AdminAuditPage } from "@/pages/AdminAuditPage";
import { AdminUsersPage } from "@/pages/AdminUsersPage";
import { AdminChannelsPage } from "@/pages/AdminChannelsPage";
import { AdminEnvironmentProvidersPage } from "@/pages/AdminEnvironmentProvidersPage";
@@ -153,6 +154,7 @@ export function App() {
{admin && (
<>
} />
+ } />
} />
} />
} />
diff --git a/platform/web/src/components/admin/deleted-sessions.test.tsx b/platform/web/src/components/admin/deleted-sessions.test.tsx
new file mode 100644
index 000000000..65eb9a9f7
--- /dev/null
+++ b/platform/web/src/components/admin/deleted-sessions.test.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { DeletedSessionsDialog } from "./deleted-sessions";
+
+const mocks = vi.hoisted(() => ({ api: vi.fn() }));
+vi.mock("@/api", () => ({ api: mocks.api }));
+let root: Root;
+let container: HTMLDivElement;
+let client: QueryClient;
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("PointerEvent", MouseEvent);
+ client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ mocks.api.mockReset().mockResolvedValue({ sessions: [{ sessionId: "s1", displayName: "Deleted work", deletedAtMs: 1 }], nextAfter: null });
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ client.clear(); container.remove(); vi.useRealTimers(); vi.unstubAllGlobals();
+});
+async function settle() {
+ for (let step = 0; step < 4; step++) await act(async () => { await vi.advanceTimersByTimeAsync(5); });
+}
+const button = (label: string) => [...document.body.querySelectorAll("button")].find((node) => node.textContent === label)!;
+async function show() {
+ await act(async () => root.render( undefined} />));
+ await settle();
+}
+it("lists deleted sessions and requires confirmation before permanent deletion", async () => {
+ await show();
+ expect(mocks.api).toHaveBeenCalledWith("GET", "/api/v1/admin/universes/u/deleted-sessions");
+ await act(async () => button("Permanently delete…").click());
+ expect(document.body.textContent).toContain("This cannot be undone");
+ expect(mocks.api.mock.calls.some(([method]) => method === "POST")).toBe(false);
+ await act(async () => button("Permanently delete").click());
+ await settle();
+ expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/admin/universes/u/sessions/s1/purge", {});
+});
+it("preserves the confirmation and shows failed permanent deletion", async () => {
+ await show();
+ mocks.api.mockRejectedValue(new Error("session must be soft-deleted first"));
+ await act(async () => button("Permanently delete…").click());
+ await act(async () => button("Permanently delete").click());
+ await settle();
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe("session must be soft-deleted first");
+ expect(button("Permanently delete")).toBeDefined();
+});
diff --git a/platform/web/src/components/admin/deleted-sessions.tsx b/platform/web/src/components/admin/deleted-sessions.tsx
new file mode 100644
index 000000000..03a0a5586
--- /dev/null
+++ b/platform/web/src/components/admin/deleted-sessions.tsx
@@ -0,0 +1,56 @@
+import type { DeploymentDeletedSessionsListResponse, DeletedSessionView } from "@lightspeed-ai/sdk";
+import { useState } from "react";
+import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "@/api";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { LoadingNote } from "@/components/page";
+
+/** Mounted only in Platform administration, never in the universe browser. */
+export function DeletedSessionsDialog({ universe, onClose }: { universe: { id: string; name: string } | null; onClose: () => void }) {
+ const queryClient = useQueryClient();
+ const [selected, setSelected] = useState(null);
+ const base = `/api/v1/admin/universes/${encodeURIComponent(universe?.id ?? "")}`;
+ const sessions = useInfiniteQuery({
+ queryKey: ["admin", "deleted-sessions", universe?.id], enabled: !!universe,
+ initialPageParam: undefined as string | undefined,
+ queryFn: ({ pageParam }) => api("GET", `${base}/deleted-sessions${pageParam ? `?after=${encodeURIComponent(pageParam)}` : ""}`),
+ getNextPageParam: (page) => page.nextAfter ?? undefined,
+ });
+ const purge = useMutation({
+ mutationFn: (sessionId: string) => api("POST", `${base}/sessions/${encodeURIComponent(sessionId)}/purge`, {}),
+ onSuccess: () => {
+ setSelected(null);
+ void queryClient.invalidateQueries({ queryKey: ["admin", "deleted-sessions", universe?.id] });
+ void queryClient.invalidateQueries({ queryKey: ["admin", "audit"] });
+ },
+ });
+ const close = () => { if (!purge.isPending) { setSelected(null); purge.reset(); onClose(); } };
+ return ;
+}
diff --git a/platform/web/src/components/app-shell.test.tsx b/platform/web/src/components/app-shell.test.tsx
index 02b6f22bc..4e69e0f04 100644
--- a/platform/web/src/components/app-shell.test.tsx
+++ b/platform/web/src/components/app-shell.test.tsx
@@ -42,17 +42,17 @@ afterEach(async () => {
});
/// The sidebar as label → nav items, with the unlabelled first group as "".
-async function sidebarFor(role: string, features = { bots: true, channels: true }): Promise> {
+async function sidebarFor(role: string, features = { bots: true, channels: true }, platformAdmin = false): Promise> {
mocks.role = role;
mocks.api.mockReset().mockImplementation(async (_method: string, path: string) => {
if (path === "/api/v1/universes") return [{ id: "universe", slug: "test", name: "Test", status: "active", role: mocks.role, features }];
throw new Error(`Unexpected request: ${path}`);
});
await act(async () => root.render(
-
-
+
+
- }>
+ }>
@@ -144,3 +144,9 @@ it("keeps the menu folded to icons from the account's preference, and ⌘B unfol
expect(JSON.parse(localStorage.getItem(key)!).sidebarCollapsed).toBe(false);
localStorage.removeItem(key);
});
+
+it("offers Audit log in Platform admin navigation", async () => {
+ const groups = await sidebarFor("admin", { bots: true, channels: true }, true);
+ expect(groups["Platform admin"]).toContain("Audit log");
+ expect(container.querySelector('a[href="/admin/audit"]')?.getAttribute("aria-current")).toBe("page");
+});
diff --git a/platform/web/src/components/app-shell.tsx b/platform/web/src/components/app-shell.tsx
index 3e7ba154b..3689f3dd5 100644
--- a/platform/web/src/components/app-shell.tsx
+++ b/platform/web/src/components/app-shell.tsx
@@ -3,6 +3,7 @@ import { useEffect, type ComponentType, type CSSProperties } from "react";
import { Link, NavLink, Outlet, useLocation, useMatch } from "react-router-dom";
import {
ArrowLeft,
+ ScrollText,
Globe,
KeyRound,
Palette,
@@ -185,6 +186,7 @@ export function AppShell({ user, admin }: { user: SessionUser; admin: boolean })
+
- Delete erases the bot, its triggers, its event history, and
- its conversations, and frees the id{closed ? "." : " — it closes the bot first."} Environments and
+ Delete erases the bot, its triggers, and its event history,
+ retains its conversations, and frees the id{closed ? "." : " — it closes the bot first."} Environments and
profiles are never deleted with a bot.
@@ -1084,8 +1084,8 @@ function DangerSection({
Delete {botLabel(bot)}?
{closed
- ? "The record, its event history, and its conversations are erased; the id becomes available again."
- : "The bot is closed first (runs cancelled, conversations closed, events refused), then the record, its event history, and its conversations are erased and the id becomes available again."}
+ ? "The record and its event history are erased. Conversation history is retained; the id becomes available again."
+ : "The bot is closed first (runs cancelled, conversations closed, events refused), then the record and its event history are erased. Conversation history is retained and the id becomes available again."}
diff --git a/platform/web/src/components/session/session-settings-sheet.tsx b/platform/web/src/components/session/session-settings-sheet.tsx
index 233c62b5a..69b4219de 100644
--- a/platform/web/src/components/session/session-settings-sheet.tsx
+++ b/platform/web/src/components/session/session-settings-sheet.tsx
@@ -131,7 +131,7 @@ function LiveSessionSetup({
const [originalActiveEnvironmentId, setOriginalActiveEnvironmentId] = useState(null);
const [metadataRows, setMetadataRows] = useState([]);
const [originalMetadata, setOriginalMetadata] = useState>({});
- const canSetRetention = useActionPermissions(universeId).can("delete_session");
+ const canSetRetention = useActionPermissions(universeId).can("set_session_retention");
const [retentionDaysDraft, setRetentionDaysDraft] = useState("");
const [originalRetentionDays, setOriginalRetentionDays] = useState("");
const [configError, setConfigError] = useState(null);
diff --git a/platform/web/src/demo/router.test.ts b/platform/web/src/demo/router.test.ts
index f1bd676b9..531620dbe 100644
--- a/platform/web/src/demo/router.test.ts
+++ b/platform/web/src/demo/router.test.ts
@@ -630,7 +630,7 @@ describe("demo router", () => {
);
it("session start, close, and deletion leave environments independently managed", async () => {
- const { call } = await boot();
+ const { call, store } = await boot();
const base = `/api/v1/universes/${SOFTWARE_FACTORY_UNIVERSE_ID}`;
const before = (await call("GET", `${base}/environments`)).json as Environment[];
const profile = (await call("GET", `${base}/profiles/implementer`)).json as { config: unknown };
@@ -644,7 +644,18 @@ describe("demo router", () => {
expect(((await call("GET", `${base}/environments`)).json as Environment[]).map((env) => env.environmentId))
.toEqual(before.map((env) => env.environmentId));
expect((await call("POST", `${base}/sessions/${session.id}/close`, { force: true })).status).toBe(200);
+ expect(store.auditEvents.filter((event) => event.targetId === session.id)).toEqual([]);
expect((await call("DELETE", `${base}/sessions/${session.id}`)).status).toBe(200);
+ expect(store.auditEvents.filter((event) => event.targetId === session.id)).toEqual([]);
+ expect((await call("GET", `${base}/sessions/${session.id}`)).status).toBe(404);
+ const adminBase = `/api/v1/admin/universes/${SOFTWARE_FACTORY_UNIVERSE_ID}`;
+ const deleted = (await call("GET", `${adminBase}/deleted-sessions`)).json as { sessions: { sessionId: string }[] };
+ expect(deleted.sessions.map((entry) => entry.sessionId)).toContain(session.id);
+ expect(store.deletedSessions.size).toBe(1);
+ expect((await call("POST", `${adminBase}/sessions/${session.id}/purge`, {})).status).toBe(200);
+ expect(store.deletedSessions.size).toBe(0);
+ const audit = (await call("GET", "/api/v1/admin/audit")).json as { action: string; targetId: string }[];
+ expect(audit.filter((event) => event.targetId === session.id).map((event) => event.action)).toEqual(["session.purge"]);
const after = (await call("GET", `${base}/environments`)).json as Environment[];
expect(after.find((env) => env.environmentId === existing.environmentId)).toEqual(existing);
});
diff --git a/platform/web/src/demo/router.ts b/platform/web/src/demo/router.ts
index ec9545e82..5a5244bc1 100644
--- a/platform/web/src/demo/router.ts
+++ b/platform/web/src/demo/router.ts
@@ -19,7 +19,37 @@ export function createDemoRouter(store: DemoStore): Hono {
const app = new Hono();
app.get("/health", (c) => c.json({ ok: true, demo: true }));
app.get("/api/login-config", (c) => c.json({ sso: false, providerId: null, password: "local", autoSignIn: false }));
- app.get("/api/v1/admin/audit", (c) => c.json([]));
+ app.get("/api/v1/admin/audit", (c) => store.currentUser.role === "admin"
+ ? c.json(store.auditEvents.slice(-100).reverse()) : c.json({ error: "platform admin required" }, 403));
+ app.get("/api/v1/admin/universes/:id/deleted-sessions", (c) => {
+ if (store.currentUser.role !== "admin") return c.json({ error: "platform admin required" }, 403);
+ const prefix = `${c.req.param("id")}:`;
+ const sessions = [...store.deletedSessions.entries()].filter(([key]) => key.startsWith(prefix))
+ .map(([, { record, deletedAtMs }]) => ({ sessionId: record.view.id, displayName: record.view.displayName ?? null, deletedAtMs }))
+ .sort((a, b) => a.sessionId.localeCompare(b.sessionId)).filter((session) => !c.req.query("after") || session.sessionId > c.req.query("after")!);
+ return c.json({ sessions: sessions.slice(0, 100), nextAfter: sessions.length > 100 ? sessions[99]!.sessionId : null });
+ });
+ app.post("/api/v1/admin/universes/:id/sessions/:sessionId/purge", (c) => {
+ if (store.currentUser.role !== "admin") return c.json({ error: "platform admin required" }, 403);
+ const universeId = c.req.param("id");
+ const sessionId = c.req.param("sessionId");
+ if (store.universes.get(universeId)?.sessions.has(sessionId)) return c.json({ error: "session must be soft-deleted first" }, 409);
+ const all = [...store.deletedSessions.entries()].filter(([key]) => key.startsWith(`${universeId}:`));
+ const ids = new Set();
+ if (store.deletedSessions.has(`${universeId}:${sessionId}`)) ids.add(sessionId);
+ let previous = -1;
+ while (previous !== ids.size) {
+ previous = ids.size;
+ for (const [, { record }] of all) {
+ const view = record.view;
+ if ((view.origin?.parentSessionId && ids.has(view.origin.parentSessionId))) ids.add(view.id);
+ }
+ }
+ for (const id of ids) store.deletedSessions.delete(`${universeId}:${id}`);
+ if (ids.size) store.auditEvents.push({ id: store.nextId("audit"), universeId, targetId: sessionId,
+ actorId: store.currentUser.id, action: "session.purge", outcome: "success", createdAt: new Date().toISOString(), details: { deletedSessionIds: [...ids] } });
+ return c.json({ deletedSessionIds: [...ids] });
+ });
app.route("/api/auth", authRoutes(store));
// The public webhook ingress lives outside /api, exactly like the core's
// POST /hooks/bots/{universe}/{bot}/{trigger}/{token} route.
diff --git a/platform/web/src/demo/routes/bots.ts b/platform/web/src/demo/routes/bots.ts
index 8abbfb609..3a31cd3f3 100644
--- a/platform/web/src/demo/routes/bots.ts
+++ b/platform/web/src/demo/routes/bots.ts
@@ -1126,9 +1126,6 @@ export function botRoutes(store: DemoStore): Hono {
const { universe, record } = found;
closeBot(universe, record);
const deletedSessions: string[] = [];
- for (const sessionId of record.bot.closedSessions ?? []) {
- if (universe.sessions.delete(sessionId)) deletedSessions.push(sessionId);
- }
universe.channelPairings = universe.channelPairings.filter(
(pairing) => pairing.botId !== record.bot.botId,
);
diff --git a/platform/web/src/demo/routes/sessions.ts b/platform/web/src/demo/routes/sessions.ts
index 8cef0a6d0..78ca2508d 100644
--- a/platform/web/src/demo/routes/sessions.ts
+++ b/platform/web/src/demo/routes/sessions.ts
@@ -9,6 +9,8 @@ import {
MAX_ATTACHMENT_BYTES,
attachmentUploadSchema,
roleAtLeast,
+ canCloseSession,
+ canDeleteSession,
sessionMessageSchema,
sessionSteerSchema,
type MessageAttachment,
@@ -117,6 +119,8 @@ export function sessionRoutes(store: DemoStore): Hono {
}>(c);
if (Object.hasOwn(body, "environment")) return badRequest(c, "environment is not a session creation field; configure environment attachments instead");
if (!body.profile) return badRequest(c, "profile is required");
+ const admin = store.currentUser.role === "admin" || universe.universe.role === "admin";
+ if (!admin && body.deleteAfterCloseMs != null) return c.json({ error: "admin role required for deletion retention" }, 403);
const profile = resolveProfile(universe, body.profile);
if (!profile) return notFound(c, "not found in engine");
const config = sessionConfig(profile.config, universe.modelDefaults.agentRun);
@@ -128,7 +132,7 @@ export function sessionRoutes(store: DemoStore): Hono {
id: sessionId,
displayName: body.displayName?.trim() || null,
metadata: { ...profile.metadata, ...(body.metadata ?? {}) },
- deleteAfterCloseMs: Object.hasOwn(body, "deleteAfterCloseMs")
+ deleteAfterCloseMs: !admin ? null : Object.hasOwn(body, "deleteAfterCloseMs")
? body.deleteAfterCloseMs
: profile.retention?.deleteAfterCloseMs,
config,
@@ -171,6 +175,7 @@ export function sessionRoutes(store: DemoStore): Hono {
app.put("/:id/sessions/:sessionId/retention", async (c) => {
const found = lookup(c);
if (!found) return notFound(c, "not found in engine");
+ if (!canDeleteSession(store.currentUser.role === "admin" ? "admin" : found.universe.universe.role, found.session.view.access.visibility === "universe")) return c.json({ error: "not permitted to delete this session" }, 403);
const { session } = found;
if (session.view.retention.rootSessionId !== session.view.id) {
return conflict(
@@ -212,6 +217,11 @@ export function sessionRoutes(store: DemoStore): Hono {
const found = lookup(c);
if (!found) return notFound(c, "not found in engine");
const { session } = found;
+ const role = store.currentUser.role === "admin" ? "admin" : found.universe.universe.role;
+ const access = session.view.access;
+ if (!canCloseSession(role, access.createdBy?.kind === "actor" && access.createdBy.id === store.currentUser.id, access.visibility === "universe")) {
+ return c.json({ error: "not permitted to close this session" }, 403);
+ }
const body = await readBody<{ force?: boolean }>(c);
if (!closeSession(session, body.force === true)) {
return conflict(c, "engine conflict: session has active work; close with force to cancel it");
@@ -222,6 +232,7 @@ export function sessionRoutes(store: DemoStore): Hono {
app.delete("/:id/sessions/:sessionId", (c) => {
const found = lookup(c);
if (!found) return notFound(c, "not found in engine");
+ if (!canDeleteSession(store.currentUser.role === "admin" ? "admin" : found.universe.universe.role, found.session.view.access.visibility === "universe")) return c.json({ error: "not permitted to delete this session" }, 403);
const { universe, session } = found;
if (session.view.status !== "closed") {
return conflict(c, "engine conflict: only closed sessions can be deleted");
@@ -235,11 +246,17 @@ export function sessionRoutes(store: DemoStore): Hono {
if (selected.some((candidate) => candidate.view.status !== "closed")) {
return conflict(c, "engine conflict: every session in the subtree must be closed");
}
+ const role = store.currentUser.role === "admin" ? "admin" : universe.universe.role;
+ if (selected.some((candidate) => !canDeleteSession(role, candidate.view.access.visibility === "universe"))) {
+ return c.json({ error: "not permitted to delete this session subtree" }, 403);
+ }
+ const deletedAtMs = Date.now();
for (const candidate of selected.reverse()) {
for (const timer of candidate.timers) clearTimeout(timer);
candidate.timers.clear();
// A parked tail returns now instead of waiting out its poll.
for (const wake of [...candidate.waiters]) wake();
+ store.deletedSessions.set(`${universe.universe.id}:${candidate.view.id}`, { record: candidate, deletedAtMs });
universe.sessions.delete(candidate.view.id);
}
return c.json(sessionSummary(session));
diff --git a/platform/web/src/demo/store.ts b/platform/web/src/demo/store.ts
index 74e15e7ab..ca406d734 100644
--- a/platform/web/src/demo/store.ts
+++ b/platform/web/src/demo/store.ts
@@ -180,6 +180,8 @@ const fallbackResponder: DemoResponder = (input) => ({
});
export class DemoStore {
+ readonly auditEvents: Array<{ id: string; createdAt: string; actorId: string; targetId: string; universeId: string; action: string; outcome: string; details: Record }> = [];
+ readonly deletedSessions = new Map();
readonly users = new Map();
currentUser: DemoUser;
readonly universes = new Map();
diff --git a/platform/web/src/lib/method-groups.ts b/platform/web/src/lib/method-groups.ts
index f91c78c80..86c866749 100644
--- a/platform/web/src/lib/method-groups.ts
+++ b/platform/web/src/lib/method-groups.ts
@@ -11,6 +11,8 @@ export const METHOD_GROUPS: Record {
expect(offered("read")).toEqual(["viewer", "contributor", "operator", "admin"]);
expect(offered("control_session")).toEqual(["contributor", "operator", "admin"]);
expect(offered("configure_session")).toEqual(["operator", "admin"]);
- expect(offered("delete_session")).toEqual(["admin"]);
+ expect(offered("delete_session")).toEqual(["operator", "admin"]);
+ expect(offered("set_session_retention")).toEqual(["admin"]);
expect(offered("close_session")).toEqual(["contributor", "operator", "admin"]);
expect(offered("share_session")).toEqual(["contributor", "operator", "admin"]);
expect(offered("configure_resource")).toEqual(["operator", "admin"]);
diff --git a/platform/web/src/lib/permissions.tsx b/platform/web/src/lib/permissions.tsx
index 9fa369293..276688f44 100644
--- a/platform/web/src/lib/permissions.tsx
+++ b/platform/web/src/lib/permissions.tsx
@@ -1,6 +1,6 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ResourceAccessSummary, UniverseAction } from "@lightspeed-ai/sdk";
-import { canCloseSession, roleAtLeast, universeRoleSchema, type UniverseRole } from "@lightspeed-ai/platform-shared";
+import { canCloseSession, canDeleteSession, roleAtLeast, universeRoleSchema, type UniverseRole } from "@lightspeed-ai/platform-shared";
import { useActiveUniverse } from "@/lib/universes";
type Identity = { userId: string; platformAdmin: boolean };
@@ -23,7 +23,8 @@ const ACTION_ROLES: Record = {
configure_session: "operator",
stop_session: "contributor",
close_session: "contributor",
- delete_session: "admin",
+ delete_session: "operator",
+ set_session_retention: "admin",
share_session: "contributor",
invoke_bot: "contributor",
use_resource: "contributor",
@@ -78,3 +79,8 @@ export function useSessionClosePermission(universeId: string | undefined) {
access.visibility === "universe",
);
}
+
+export function useSessionDeletePermission(universeId: string | undefined) {
+ const role = useUniverseRole(universeId);
+ return (access: ResourceAccessSummary | undefined) => !!access && canDeleteSession(role, access.visibility === "universe");
+}
diff --git a/platform/web/src/pages/AdminAuditPage.test.tsx b/platform/web/src/pages/AdminAuditPage.test.tsx
new file mode 100644
index 000000000..9d0e9e4cc
--- /dev/null
+++ b/platform/web/src/pages/AdminAuditPage.test.tsx
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { AdminAuditPage } from "./AdminAuditPage";
+
+const mocks = vi.hoisted(() => ({ api: vi.fn(), listUsers: vi.fn() }));
+vi.mock("@/api", () => ({ api: mocks.api }));
+vi.mock("@/auth", () => ({ authClient: { admin: { listUsers: mocks.listUsers } } }));
+let root: Root;
+let container: HTMLDivElement;
+let client: QueryClient;
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ mocks.api.mockReset().mockResolvedValue([
+ { id: "purge", createdAt: "2026-10-06T10:00:00Z", action: "session.purge", actorId: "admin", targetId: "removed-session", outcome: "success" },
+ { id: "access", createdAt: "2026-10-06T09:00:00Z", action: "member.add", actorId: "removed-user", targetId: "member", outcome: "success" },
+ ]);
+ mocks.listUsers.mockReset().mockResolvedValue({ data: { users: [{ id: "admin", email: "admin@example.test" }, { id: "member", email: "member@example.test" }] } });
+ client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
+ container = document.createElement("div"); document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ client.clear(); container.remove(); vi.useRealTimers(); vi.unstubAllGlobals();
+});
+async function show() {
+ await act(async () => root.render());
+ for (let step = 0; step < 5; step++) await act(async () => { await vi.advanceTimersByTimeAsync(5); });
+}
+it("loads audit events directly and resolves users while preserving deleted targets", async () => {
+ await show();
+ expect(mocks.api).toHaveBeenCalledWith("GET", "/api/v1/admin/audit");
+ expect(container.textContent).toContain("Audit log");
+ const rows = [...container.querySelectorAll("tbody tr")].map((row) => row.textContent);
+ expect(rows[0]).toContain("Session permanently deleted");
+ expect(rows[0]).toContain("admin@example.test");
+ expect(rows[0]).toContain("removed-session");
+ expect(rows[1]).toContain("removed-user");
+ expect(rows[1]).toContain("member@example.test");
+});
+it("shows an empty audit log explicitly", async () => {
+ mocks.api.mockResolvedValue([]);
+ await show();
+ expect(container.textContent).toContain("No audit events recorded.");
+});
+it("shows read errors instead of an empty log", async () => {
+ mocks.api.mockRejectedValue(new Error("Audit service unavailable"));
+ await show();
+ expect(container.textContent).toContain("Audit service unavailable");
+ expect(container.textContent).not.toContain("No audit events recorded.");
+});
diff --git a/platform/web/src/pages/AdminAuditPage.tsx b/platform/web/src/pages/AdminAuditPage.tsx
new file mode 100644
index 000000000..93a4d78e3
--- /dev/null
+++ b/platform/web/src/pages/AdminAuditPage.tsx
@@ -0,0 +1,67 @@
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/api";
+import { authClient } from "@/auth";
+import { LoadingNote, PageHeader } from "@/components/page";
+import { ReadError } from "@/components/read-error";
+import { Table, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+
+interface AuditEvent {
+ id: string;
+ createdAt: string;
+ action: string;
+ actorId: string | null;
+ targetId: string | null;
+ outcome: string;
+}
+
+export function AdminAuditPage() {
+ const audit = useQuery({
+ queryKey: ["admin", "audit"],
+ queryFn: () => api("GET", "/api/v1/admin/audit"),
+ });
+ const users = useQuery({
+ queryKey: ["admin", "users"],
+ queryFn: async () => {
+ const result = await authClient.admin.listUsers({ query: { limit: 200, sortBy: "createdAt" } });
+ if (result.error) throw new Error(result.error.message ?? "failed to load users");
+ return result.data.users;
+ },
+ });
+ const label = (id: string | null, fallback: string) => users.data?.find((user) => user.id === id)?.email ?? id ?? fallback;
+
+ return <>
+
+ {audit.error && }
+ {audit.isFetching && }
+ {audit.data &&
}
-
>
);
}
@@ -527,17 +508,3 @@ function CreateUserDialog({
);
}
-
-function auditAction(action: string): string {
- const labels: Record = {
- "company.access": "Company access updated", "company.sign_in": "Company sign-in",
- "emergency.sign_in": "Emergency sign-in", "password.sign_in": "Password sign-in",
- "user.suspend": "User suspended", "user.reinstate": "User reinstated",
- "session.revoke_all": "All sessions signed out", "member.add": "Universe member added",
- "member.role": "Universe role changed", "member.remove": "Universe member removed",
- "key.create": "API key created", "key.revoke": "API key revoked", "key.rotate": "API key rotated",
- "create_user": "User created", "update_user": "User updated", "set_user_password": "Password reset",
- "emergency.designate": "Emergency admin designated", "emergency.create": "Emergency admin created",
- };
- return labels[action] ?? action;
-}
diff --git a/platform/web/src/pages/SessionsPage.permissions.test.tsx b/platform/web/src/pages/SessionsPage.permissions.test.tsx
index 31177a864..6c81e8135 100644
--- a/platform/web/src/pages/SessionsPage.permissions.test.tsx
+++ b/platform/web/src/pages/SessionsPage.permissions.test.tsx
@@ -144,5 +144,17 @@ it("offers operators bulk close for their own and shared sessions", async () =>
await act(async () => container.querySelector('[aria-label="Select sessions"]')!.click());
await act(async () => container.querySelector('[aria-label="Select all listed sessions"]')!.click());
expect([...container.querySelectorAll("button")].some((button) => button.textContent === "Close 2")).toBe(true);
- expect([...container.querySelectorAll("button")].some((button) => button.textContent?.startsWith("Delete"))).toBe(false);
+ expect([...container.querySelectorAll("button")].find((button) => button.textContent?.startsWith("Delete"))?.disabled).toBe(true);
+});
+
+it("offers operators bulk deletion only for closed shared sessions, including managed history", async () => {
+ mocks.role = "operator";
+ mocks.api.mockImplementation(async (_method: string, path: string) => {
+ if (path.includes("/sessions?")) return { sessions: sessions.map((session) => ({ ...session, lifecycleStatus: "closed", managed: true })) };
+ return [];
+ });
+ await show();
+ await act(async () => container.querySelector('[aria-label="Select sessions"]')!.click());
+ await act(async () => container.querySelector('[aria-label="Select all listed sessions"]')!.click());
+ expect([...container.querySelectorAll("button")].some((button) => button.textContent === "Delete 1" && !button.disabled)).toBe(true);
});
diff --git a/platform/web/src/pages/SessionsPage.test.tsx b/platform/web/src/pages/SessionsPage.test.tsx
index 915573b86..5b52a30cd 100644
--- a/platform/web/src/pages/SessionsPage.test.tsx
+++ b/platform/web/src/pages/SessionsPage.test.tsx
@@ -26,6 +26,8 @@ vi.mock("@/api", async (original) => ({
vi.mock("@/lib/sessions/tail", () => ({ useSessionTail: mocks.tail }));
vi.mock("@/lib/permissions", () => ({
useActionPermissions: () => ({ can: (action: string) => mocks.permissions.has(action), isLoading: mocks.permissionLoading }),
+ useSessionDeletePermission: () => () => mocks.permissions.has("delete_session"),
+ useSessionClosePermission: () => () => mocks.permissions.has("close_session"),
usePermissionIdentity: () => "user",
useUniverseRole: () => "contributor",
}));
diff --git a/platform/web/src/pages/SessionsPage.tsx b/platform/web/src/pages/SessionsPage.tsx
index 9e7b72872..887b1851d 100644
--- a/platform/web/src/pages/SessionsPage.tsx
+++ b/platform/web/src/pages/SessionsPage.tsx
@@ -123,7 +123,7 @@ import {
} from "@/lib/sessions/resource-features";
import { ProviderReadinessBanner } from "@/components/provider-readiness-banner";
import { modelFromConfig, modelLabel, resolveCreationModel, useModelDefaults, useModelDiscovery } from "@/lib/model-defaults";
-import { useActionPermissions, useSessionClosePermission } from "@/lib/permissions";
+import { useActionPermissions, useSessionClosePermission, useSessionDeletePermission } from "@/lib/permissions";
import { useActiveUniverse, useFeature } from "@/lib/universes";
import { cn } from "@/lib/utils";
import {
@@ -243,10 +243,11 @@ function SessionList({
const selectedSessions = sessions.filter((session) => selected.has(session.id));
const permissions = useActionPermissions(universeId);
const mayClose = useSessionClosePermission(universeId);
+ const mayDelete = useSessionDeletePermission(universeId);
const canCreate = permissions.can("create_session");
- const canSelect = sessions.some((session) => (!session.managed || permissions.role === "admin") && (session.lifecycleStatus === "closed" ? permissions.can("delete_session") : mayClose(session.access)));
+ const canSelect = sessions.some((session) => session.lifecycleStatus === "closed" ? mayDelete(session.access) : (!session.managed || permissions.role === "admin") && mayClose(session.access));
const selectedOpen = selectedSessions.filter((session) => (!session.managed || permissions.role === "admin") && session.lifecycleStatus !== "closed" && mayClose(session.access));
- const selectedClosed = selectedSessions.filter((session) => (!session.managed || permissions.role === "admin") && session.lifecycleStatus === "closed" && permissions.can("delete_session"));
+ const selectedClosed = selectedSessions.filter((session) => session.lifecycleStatus === "closed" && mayDelete(session.access));
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
// Only metadata filters count; which sessions the list includes is its scope.
const activeFilterCount = filterEntries.length;
@@ -727,7 +728,7 @@ function BulkActionDialog({
{action === "close"
? "Each permitted open session is force-closed in turn: active and queued work is cancelled and the session cannot be reopened. Other selected sessions are left alone."
- : "Each permitted closed session disappears for all universe members. Retained records are purged after 30 days. Other selected sessions are left alone."}
+ : "Each permitted closed session disappears for all universe members. History is retained. Other selected sessions are left alone."}
@@ -929,7 +930,7 @@ function NewSessionDialog({
onOpenChange: (open: boolean) => void;
search: string;
}) {
- const canSetRetention = useActionPermissions(universeId).can("delete_session");
+ const canSetRetention = useActionPermissions(universeId).can("set_session_retention");
const [displayName, setDisplayName] = useState("");
const [profileId, setProfileId] = useState("");
const canConfigure = useActionPermissions(universeId).can("configure_session");
@@ -1353,11 +1354,12 @@ export function SessionDetail({
const [deleteCascade, setDeleteCascade] = useState(false);
const permissions = useActionPermissions(universeId);
const mayClose = useSessionClosePermission(universeId);
+ const mayDelete = useSessionDeletePermission(universeId);
const canControl = permissions.can("control_session");
const canConfigure = permissions.can("configure_session");
const canStop = permissions.can("stop_session");
const canClose = mayClose(session.data?.access);
- const canDelete = permissions.can("delete_session");
+ const canDelete = mayDelete(session.data?.access);
const [settingsOpen, setSettingsOpen] = useState(false);
const [decidingApproval, setDecidingApproval] = useState<{
approvalId: string;
@@ -1942,7 +1944,7 @@ export function SessionDetail({
onCompact={canControl && session.data && !closed ? compaction.compact : undefined}
compactionLabel={compaction.label}
onShare={canShare ? () => setShareOpen(true) : undefined}
- lifecycle={(!managed || permissions.role === "admin") && ((canClose && !closed) || (canDelete && closed)) ? (
+ lifecycle={((!managed || permissions.role === "admin") && canClose && !closed) || (canDelete && closed) ? (
closed ? (
Delete this session?
- This hides the session and its history from all universe members, including admins. Retained records are permanently purged after 30 days.
+ This hides the session and its history from all universe members, including admins. History is retained until a platform admin permanently deletes it.
A session with history forks or delegated children cannot be deleted
unless cascade is enabled.
@@ -2049,7 +2051,7 @@ export function SessionDetail({
Also delete forks and delegated children
- Every descendant must already be closed. Deleted sessions disappear for all universe members; retained records are purged after 30 days. Config-only clones are not included.
+ Every descendant must already be closed. Deleted sessions disappear for all universe members; history is retained. Config-only clones are not included.
diff --git a/release/metadata.env b/release/metadata.env
index 5bcabf3a5..6d5ccac31 100644
--- a/release/metadata.env
+++ b/release/metadata.env
@@ -11,7 +11,7 @@ LIGHTSPEED_API_PROTOCOL_VERSION=lightspeed.agent.api.v1
# The environment protocol number the gateway and envd must agree on exactly;
# a change here is the release event that stops older daemons from registering.
LIGHTSPEED_ENVIRONMENT_PROTOCOL_VERSION=2
-LIGHTSPEED_SCHEMA_REVISION=10
+LIGHTSPEED_SCHEMA_REVISION=11
# Drizzle journal length and the oldest platform migration boundary accepted by
# the current release's automated upgrade gate.
LIGHTSPEED_PLATFORM_SCHEMA_REVISION=4
From f5920f6b2e3545040abc80422e6402c1bdff7c92 Mon Sep 17 00:00:00 2001
From: lb <542828+lukebuehler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:07:49 +0200
Subject: [PATCH 4/8] api key healing in dev
---
crates/store-pg/src/api_keys.rs | 32 +++++++++-
crates/store-pg/tests/api_keys_live.rs | 85 ++++++++++++++++++++++++--
crates/temporal-runtime/src/main.rs | 12 +++-
scripts/dev/cli-connection.mjs | 5 +-
scripts/dev/cli-connection.test.mjs | 33 ++++++++--
5 files changed, 148 insertions(+), 19 deletions(-)
diff --git a/crates/store-pg/src/api_keys.rs b/crates/store-pg/src/api_keys.rs
index 105dbe872..432c157b2 100644
--- a/crates/store-pg/src/api_keys.rs
+++ b/crates/store-pg/src/api_keys.rs
@@ -57,12 +57,13 @@ impl PgApiKeyStore {
Ok(())
}
- /// Idempotent host-side provisioning. Conflicts never resurrect revoked
- /// credentials or change their scope, groups, or actor authority.
+ /// Idempotent host-side provisioning. Explicit group refresh updates active
+ /// credentials only; scope and actor authority must still match.
pub async fn provision_api_key(
&self,
key: &auth::MintedApiKey,
require_existing: bool,
+ refresh_groups: bool,
) -> Result {
if !require_existing {
match self.create_api_key(&key.key_hash, &key.record).await {
@@ -71,6 +72,33 @@ impl PgApiKeyStore {
Err(error) => return Err(error),
}
}
+ if refresh_groups {
+ let groups: Vec<&str> = key
+ .record
+ .groups
+ .iter()
+ .map(|group| group.as_str())
+ .collect();
+ // Match authority and revocation in the write itself so concurrent
+ // revocation cannot be undone by development startup.
+ let row = sqlx::query(sqlx::AssertSqlSafe(format!(
+ "UPDATE api_keys SET groups = $2
+ WHERE key_hash = $1 AND revoked_at_ms IS NULL
+ AND universe_id IS NOT DISTINCT FROM $3 AND assert_actor = $4
+ AND groups IS DISTINCT FROM $2
+ RETURNING {KEY_COLUMNS}"
+ )))
+ .bind(&key.key_hash)
+ .bind(groups)
+ .bind(key.record.scope.universe_id())
+ .bind(key.record.assert_actor)
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(map_sqlx_error)?;
+ if let Some(row) = row {
+ return record_from_row(&row);
+ }
+ }
let row = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT {KEY_COLUMNS} FROM api_keys WHERE key_hash = $1"
)))
diff --git a/crates/store-pg/tests/api_keys_live.rs b/crates/store-pg/tests/api_keys_live.rs
index 15aa0a646..3cfc27ba6 100644
--- a/crates/store-pg/tests/api_keys_live.rs
+++ b/crates/store-pg/tests/api_keys_live.rs
@@ -117,28 +117,101 @@ async fn exercise(pool: &sqlx::PgPool) {
);
assert_eq!(
- api_keys.provision_api_key(&gate_key, false).await.unwrap(),
+ api_keys
+ .provision_api_key(&gate_key, false, false)
+ .await
+ .unwrap(),
gate_key.record
);
assert_eq!(
- api_keys.provision_api_key(&gate_key, true).await.unwrap(),
+ api_keys
+ .provision_api_key(&gate_key, true, false)
+ .await
+ .unwrap(),
gate_key.record
);
let mut changed = gate_key.clone();
changed.record.assert_actor = false;
assert!(matches!(
- api_keys.provision_api_key(&changed, false).await,
+ api_keys.provision_api_key(&changed, false, false).await,
+ Err(auth::ApiKeyError::Invalid { .. })
+ ));
+ assert!(matches!(
+ api_keys.provision_api_key(&changed, false, true).await,
+ Err(auth::ApiKeyError::Invalid { .. })
+ ));
+ changed = gate_key.clone();
+ changed.record.scope = left;
+ assert!(matches!(
+ api_keys.provision_api_key(&changed, false, true).await,
Err(auth::ApiKeyError::Invalid { .. })
));
+
+ // An older development key gains newly introduced method groups without
+ // changing its secret, creation metadata, or actor assertion.
+ let older = minted(
+ AccessScope::Deployment,
+ Some(&[MethodGroup::Session]),
+ false,
+ 13,
+ );
+ api_keys
+ .create_api_key(&older.key_hash, &older.record)
+ .await
+ .unwrap();
+ let mut current = older.clone();
+ current.record.groups = MethodGroup::allowed_in(AccessScope::Deployment);
+ current.record.created_at_ms = 99;
+ current.record.display_name = Some("new provisioning name".into());
+ assert!(matches!(
+ api_keys.provision_api_key(¤t, false, false).await,
+ Err(auth::ApiKeyError::Invalid { .. })
+ ));
+ let mut expected = older.record.clone();
+ expected.groups = current.record.groups.clone();
+ assert_eq!(
+ api_keys
+ .provision_api_key(¤t, false, true)
+ .await
+ .unwrap(),
+ expected
+ );
+ assert_eq!(
+ api_keys
+ .provision_api_key(¤t, true, true)
+ .await
+ .unwrap(),
+ expected
+ );
+ assert_eq!(
+ api_keys
+ .resolve_api_key(&older.key_hash, 0)
+ .await
+ .unwrap()
+ .unwrap()
+ .groups,
+ expected.groups
+ );
+
let absent = minted(AccessScope::Deployment, None, false, 14);
- assert!(api_keys.provision_api_key(&absent, true).await.is_err());
- let provisioned = api_keys.provision_api_key(&absent, false).await.unwrap();
+ assert!(matches!(
+ api_keys.provision_api_key(&absent, true, true).await,
+ Err(auth::ApiKeyError::Invalid { .. })
+ ));
+ let provisioned = api_keys
+ .provision_api_key(&absent, false, true)
+ .await
+ .unwrap();
api_keys
.revoke_api_key(&provisioned.key_prefix, 15)
.await
.unwrap();
assert!(matches!(
- api_keys.provision_api_key(&absent, false).await,
+ api_keys.provision_api_key(&absent, false, false).await,
+ Err(auth::ApiKeyError::Invalid { .. })
+ ));
+ assert!(matches!(
+ api_keys.provision_api_key(&absent, false, true).await,
Err(auth::ApiKeyError::Invalid { .. })
));
assert!(
diff --git a/crates/temporal-runtime/src/main.rs b/crates/temporal-runtime/src/main.rs
index 7d93c3b76..47182dbbe 100644
--- a/crates/temporal-runtime/src/main.rs
+++ b/crates/temporal-runtime/src/main.rs
@@ -77,7 +77,7 @@ enum UniverseCommand {
#[derive(Debug, Subcommand)]
enum ApiKeyCommand {
- /// Provision a deployment administrator key; reuse never changes authority.
+ /// Provision a deployment administrator key; reuse validates authority by default.
/// Reads LIGHTSPEED_BOOTSTRAP_API_KEY when set, otherwise generates a key.
/// Prints the credential as JSON; store it securely.
Provision {
@@ -85,9 +85,12 @@ enum ApiKeyCommand {
name: String,
#[arg(long)]
assert_actor: bool,
- /// Validate an existing supplied key without creating it (launcher restart).
+ /// Require the supplied key to already exist in the store.
#[arg(long)]
require_existing: bool,
+ /// Refresh an active key's method groups; scope and actor authority must match.
+ #[arg(long)]
+ refresh_groups: bool,
},
#[command(about = "Mint an API key; the secret prints exactly once")]
Create {
@@ -334,6 +337,7 @@ async fn run_api_key_command(command: ApiKeyCommand) -> anyhow::Result<()> {
name,
assert_actor,
require_existing,
+ refresh_groups,
} => {
let spec = auth::ApiKeySpec {
scope: api::AccessScope::Deployment,
@@ -350,7 +354,9 @@ async fn run_api_key_command(command: ApiKeyCommand) -> anyhow::Result<()> {
Some(secret) => auth::import_api_key(spec, now_ms, secret.trim())?,
None => auth::mint_api_key(spec, now_ms)?,
};
- let record = api_keys.provision_api_key(&key, require_existing).await?;
+ let record = api_keys
+ .provision_api_key(&key, require_existing, refresh_groups)
+ .await?;
println!(
"{}",
serde_json::json!({ "keyPrefix": record.key_prefix, "secret": key.secret.expose() })
diff --git a/scripts/dev/cli-connection.mjs b/scripts/dev/cli-connection.mjs
index 3881ac33d..71e78f831 100644
--- a/scripts/dev/cli-connection.mjs
+++ b/scripts/dev/cli-connection.mjs
@@ -35,7 +35,7 @@ export async function prepareCliConnection({ root, env, full, noBootstrap, run }
async function provision(kind, supplied, assertActor) {
const file = path.join(directory, `${kind}.key`);
const exists = existsSync(file);
- if (state[kind] && !exists) {
+ if (noBootstrap && state[kind] && !exists) {
throw new DevError(`The saved ${kind} development credential is missing.`, { hint: "Explicitly provision a replacement and restore its protected credential file; startup will not silently mint another administrator key." });
}
const previous = exists ? readPrivate(file) : null;
@@ -44,7 +44,8 @@ export async function prepareCliConnection({ root, env, full, noBootstrap, run }
if (noBootstrap && !secret) return null;
const args = ["run", "-p", "temporal-runtime", "--", "api-key", "provision", "--name", `Development ${kind}: ${root}`];
if (assertActor) args.push("--assert-actor");
- if (exists || noBootstrap) args.push("--require-existing");
+ if (noBootstrap) args.push("--require-existing");
+ else args.push("--refresh-groups");
const provisioningEnv = { ...env, RUST_LOG: "off" };
delete provisioningEnv.LIGHTSPEED_BOOTSTRAP_API_KEY;
if (secret) provisioningEnv.LIGHTSPEED_BOOTSTRAP_API_KEY = secret;
diff --git a/scripts/dev/cli-connection.test.mjs b/scripts/dev/cli-connection.test.mjs
index deaaca576..3d9414be1 100644
--- a/scripts/dev/cli-connection.test.mjs
+++ b/scripts/dev/cli-connection.test.mjs
@@ -37,7 +37,7 @@ test("runtime and full profiles share a persistent CLI key and separate Platform
const second = await prepareCliConnection({ ...f, full: true });
assert.equal(readFileSync(handoff.credentialFile, "utf8"), secret);
assert.notEqual(second.platformSecret, secret);
- assert.ok(f.calls.some(c => c.args.includes("--require-existing")));
+ assert.ok(f.calls.filter(c => c.args.includes("provision")).every(c => c.args.includes("--refresh-groups")));
f.keys.set(secret, "revoked");
await assert.rejects(prepareCliConnection({ ...f, full: true }), /revoked/);
assert.equal(f.keys.size, 2, "revocation does not trigger another key");
@@ -53,12 +53,24 @@ test("key bootstrap opt-out preserves universe setup and requires existing servi
assert.equal(supplied.platformSecret, "external");
assert.equal(f.keys.size, 0);
});
-test("ordinary startup refuses saved credentials absent from the database", async t => {
+test("ordinary startup restores saved credentials absent from the database", async t => {
+ const f = fixture(t);
+ const before = await prepareCliConnection({ ...f, full: true });
+ const keys = [...f.keys.keys()];
+ f.keys.clear();
+ const after = await prepareCliConnection({ ...f, full: true });
+ assert.deepEqual([...f.keys.keys()], keys);
+ assert.equal(after.platformSecret, before.platformSecret);
+});
+test("bootstrap opt-out does not repair missing database credentials or refresh groups", async t => {
const f = fixture(t);
await prepareCliConnection({ ...f, full: true });
+ f.calls.length = 0;
+ await prepareCliConnection({ ...f, full: true, noBootstrap: true });
+ assert.ok(f.calls.filter(c => c.args.includes("provision")).every(c => c.args.includes("--require-existing") && !c.args.includes("--refresh-groups")));
f.keys.clear();
- await assert.rejects(prepareCliConnection({ ...f, full: true }), /restart must use an active key/);
- assert.equal(f.keys.size, 0, "startup does not recreate missing administrator keys");
+ await assert.rejects(prepareCliConnection({ ...f, full: true, noBootstrap: true }), /restart must use an active key/);
+ assert.equal(f.keys.size, 0);
});
test("single mode writes a credential-free handoff", async t => {
const f = fixture(t);
@@ -69,14 +81,23 @@ test("single mode writes a credential-free handoff", async t => {
assert.equal(handoff.universe, undefined);
assert.equal(f.keys.size, 0);
});
-test("supplied bootstrap input is registered once and missing local credentials require repair", async t => {
+test("missing local credentials are replaced and the handoff is repaired", async t => {
const f = fixture(t);
const result = await prepareCliConnection({ ...f, env: { ...f.env, LIGHTSPEED_BOOTSTRAP_API_KEY: "lsk_supplied" }, full: true });
const handoff = JSON.parse(readFileSync(result.handoff));
assert.equal(readFileSync(handoff.credentialFile, "utf8"), "lsk_supplied");
assert.notEqual(result.platformSecret, "lsk_supplied");
rmSync(handoff.credentialFile);
- await assert.rejects(prepareCliConnection({ ...f, full: false }), /missing/);
+ await assert.rejects(prepareCliConnection({ ...f, full: false, noBootstrap: true }), /missing/);
+ rmSync(path.join(f.root, ".lightspeed/cli/platform.key"));
+ const repaired = await prepareCliConnection({ ...f, full: true });
+ const secret = readFileSync(handoff.credentialFile, "utf8");
+ assert.notEqual(secret, "lsk_supplied");
+ assert.notEqual(repaired.platformSecret, result.platformSecret);
+ assert.equal(statSync(handoff.credentialFile).mode & 0o777, 0o600);
+ assert.deepEqual(JSON.parse(readFileSync(repaired.handoff)), handoff);
+ await prepareCliConnection({ ...f, full: true });
+ assert.equal(readFileSync(handoff.credentialFile, "utf8"), secret);
});
test("model seeding selects universes explicitly and preserves changed or cleared defaults", async () => {
From 65bda88bcfaf7b375e57c82dc93c289efb6723af Mon Sep 17 00:00:00 2001
From: lb <542828+lukebuehler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 17:22:50 +0200
Subject: [PATCH 5/8] docs
---
.../api-keys-and-service-access.md | 5 +
.../access-and-security/overview.md | 24 ++--
.../access-and-security/people-and-roles.md | 12 +-
.../private-and-shared-work.md | 12 +-
.../access-and-security/single-sign-on.md | 4 +-
docs/documentation/deployment/operations.md | 23 ++--
.../how-it-works/context-and-storage.md | 6 +-
.../using-lightspeed/bots-and-triggers.md | 5 +-
.../profiles-and-instructions.md | 7 +-
.../using-lightspeed/sessions-and-runs.md | 32 +++--
.../p190-session-deletion-and-closing.md | 13 +-
platform/README.md | 19 ++-
.../src/routes/session-lifecycle.test.ts | 14 ++
.../admin/deleted-sessions.test.tsx | 123 ++++++++++++++---
.../src/components/admin/deleted-sessions.tsx | 126 ++++++++++++++----
.../session/delete-session-dialog.test.tsx | 92 +++++++++++++
.../session/delete-session-dialog.tsx | 77 +++++++++++
platform/web/src/lib/permissions.tsx | 1 +
.../pages/SessionsPage.permissions.test.tsx | 26 ++++
platform/web/src/pages/SessionsPage.tsx | 113 +++++-----------
20 files changed, 554 insertions(+), 180 deletions(-)
create mode 100644 platform/web/src/components/session/delete-session-dialog.test.tsx
create mode 100644 platform/web/src/components/session/delete-session-dialog.tsx
diff --git a/docs/documentation/access-and-security/api-keys-and-service-access.md b/docs/documentation/access-and-security/api-keys-and-service-access.md
index 77462d1d7..1d9dba430 100644
--- a/docs/documentation/access-and-security/api-keys-and-service-access.md
+++ b/docs/documentation/access-and-security/api-keys-and-service-access.md
@@ -44,6 +44,11 @@ session mutations as well as reads and blob reads. `blobs/put` is separate so
a connector can upload attachments without reading sessions. Exact groups are
listed in the [API reference](../../../crates/api/contract/api-reference.md).
+Soft deletion and retention changes require the separate `session/delete`
+group; existing `session` keys do not gain those capabilities. Listing deleted
+sessions and permanently deleting them require a deployment key with
+`deployment/sessions`. Provision these groups on service keys that need them.
+
An actor assertion uses `x-lightspeed-actor`. Only keys created with
`assertActor` may send it. Core records the opaque actor ID for attribution;
asserting a user does not reduce the key to that person's permissions. The
diff --git a/docs/documentation/access-and-security/overview.md b/docs/documentation/access-and-security/overview.md
index a849d2d8b..141b3102f 100644
--- a/docs/documentation/access-and-security/overview.md
+++ b/docs/documentation/access-and-security/overview.md
@@ -80,16 +80,20 @@ runtime controller responsible for internal work.
The Platform also keeps a durable access history for company admission and
admin-status changes, emergency sign-ins, suspension, session revocation,
-local membership edits, and key creation/revocation through Platform routes.
-Platform admins can view the latest 100 records under **Users → Recent access
-changes**. Records contain identifiers, action, time, outcome and safe details;
-they survive deletion of users and sessions. Provider tokens and passwords
-are never included.
-
-This is a small access trail. Comprehensive gateway-operation auditing,
-retention controls and export are deferred. Core operations made directly
-with an API key do not pass through this trail. Core session attribution
-remains subject to session retention and deletion. Company OIDC sign-in is
+local membership edits, key creation/revocation, and successful permanent session
+deletion through Platform routes. Platform admins can view the latest 100 records
+under **Platform admin → Audit log**. Records contain identifiers, action, time,
+outcome and safe details; permanent deletion includes the actor, universe, target
+session and all removed session IDs. Records survive deletion of users and
+sessions. Provider tokens and passwords are never included.
+
+For sessions, only permanent deletion is audited: creation, sharing, closing,
+force-closing, retention changes and soft deletion are not. Automatic retention
+work appears in runtime operational logs, not this audit log. Core operations
+made directly with an API key do not pass through this trail. Comprehensive
+gateway-operation auditing, audit retention controls and export are deferred.
+Core session attribution remains subject to session retention and deletion.
+Company OIDC sign-in is
supported; directory synchronization, SCIM and member invitations are deferred.
## Choose the next detail
diff --git a/docs/documentation/access-and-security/people-and-roles.md b/docs/documentation/access-and-security/people-and-roles.md
index ec377e051..2059fec9d 100644
--- a/docs/documentation/access-and-security/people-and-roles.md
+++ b/docs/documentation/access-and-security/people-and-roles.md
@@ -23,7 +23,7 @@ the universe's resources.
| **Viewer** | Read visible sessions, bots, and universe resources. A session they previously created remains visible after a downgrade to Viewer. |
| **Contributor** | Create sessions with universe defaults or an existing profile; continue sessions, steer or stop work, decide tool approvals, invoke bots, and update workspace contents. |
| **Operator** | Customize session setup and run options; create workspaces and create or configure profiles, bots, environments, MCP servers, credentials, and channels; manage bot triggers and replay bot events. |
-| **Admin** | Manage universe members, settings, and API keys. Read, control, share, and delete every session, including private sessions. |
+| **Admin** | Manage universe members, settings, and API keys. Read, control, and share every session, including private sessions; soft-delete closed sessions and set deletion retention. |
Contributors can name a new session and choose its saved profile, but cannot
author inline setup, override the chosen setup, create managed sessions, or
@@ -38,15 +38,19 @@ expandable and values readable, with editing controls protected and save
actions hidden.
Contributors and Operators can start and control runs in any shared session.
-Sharing and deleting a session require its creator, with at least the
-Contributor role, or an Admin.
+Sharing a session requires its creator, with at least the Contributor role,
+or an Admin. Contributors can close only their own unshared sessions;
+Operators can close their own or shared sessions. Operators can soft-delete
+shared closed sessions; Admins can close and soft-delete any session in their
+universe, subject to lifecycle requirements.
The [private and shared work guide](private-and-shared-work.md) develops those
rules with an example.
A **Platform admin** has a separate deployment-wide role. Platform admins
create accounts and universes, manage deployment configuration, and act as
Admin in every universe even without a membership there. Giving someone an
-Admin membership in Acorn does not make them a Platform admin.
+Admin membership in Acorn does not make them a Platform admin. Only Platform
+admins can permanently delete soft-deleted session history.
## Create an account and add a member
diff --git a/docs/documentation/access-and-security/private-and-shared-work.md b/docs/documentation/access-and-security/private-and-shared-work.md
index 801264bdb..3881c039c 100644
--- a/docs/documentation/access-and-security/private-and-shared-work.md
+++ b/docs/documentation/access-and-security/private-and-shared-work.md
@@ -49,18 +49,22 @@ creator. These checks apply on the server as well as in the UI.
| Operation | Private session | Shared session |
| --- | --- | --- |
| Read | Creator and Admins | Every member |
-| Start runs, steer, cancel, approve tools, or close | Creator with Contributor or Operator role, and Admins | Contributors, Operators, and Admins |
+| Start runs, steer, cancel, or approve tools | Creator with Contributor or Operator role, and Admins | Contributors, Operators, and Admins |
+| Close or force-close | Creator with Contributor or Operator role, and Admins | Operators and Admins |
| Configure setup or override run options | Creator with Operator role, and Admins | Operators and Admins |
| Share | Creator with Contributor or Operator role, and Admins | Already shared; no reverse operation |
-| Delete | Creator with Contributor or Operator role, and Admins | Creator with Contributor or Operator role, and Admins |
+| Soft-delete a closed session | Admins | Operators and Admins |
+| Set deletion retention | Admins | Admins |
A creator downgraded to Viewer can still read their session but cannot control,
share, or delete it. An Operator does not acquire another creator's private
work by managing profiles or environments. Platform admins act as Admin in
every universe.
-Deleting a session still requires its lifecycle conditions, such as closing it
-first. See [Sessions and runs](../using-lightspeed/sessions-and-runs.md#close-and-retain-a-session)
+Soft deletion requires closing the session first and hides it even from universe
+Admins. A cascade must contain only closed sessions; Operators cannot include
+private forks. Permanent deletion requires a Platform admin and prior soft
+deletion. See [Sessions and runs](../using-lightspeed/sessions-and-runs.md#close-and-retain-a-session)
for the procedure and retention behavior.
## One root determines the audience
diff --git a/docs/documentation/access-and-security/single-sign-on.md b/docs/documentation/access-and-security/single-sign-on.md
index afc08c1c7..af5ba147d 100644
--- a/docs/documentation/access-and-security/single-sign-on.md
+++ b/docs/documentation/access-and-security/single-sign-on.md
@@ -169,11 +169,11 @@ Before opening access, test the actual provider registration:
6. Check absolute expiry and emergency password access during a provider
outage. Verify that core API keys still require separate revocation.
-Platform admins can inspect **Users → Recent access changes** for company
+Platform admins can inspect **Platform admin → Audit log** for company
admission and admin-status changes, emergency sign-ins, suspension, session
revocation, membership edits and Platform key operations. This small durable
trail survives user and session deletion; comprehensive gateway auditing,
-retention controls and export remain deferred. See
+audit retention controls and export remain deferred. See
[what is recorded](overview.md#what-is-recorded-today).
## Resolve sign-in failures
diff --git a/docs/documentation/deployment/operations.md b/docs/documentation/deployment/operations.md
index 2df16ae7e..e655c965c 100644
--- a/docs/documentation/deployment/operations.md
+++ b/docs/documentation/deployment/operations.md
@@ -150,18 +150,25 @@ another owner. Verify the new host's discovered and ready account inventory.
## Manage retention and blob collection
-Session closure, session deletion, blob collection, Temporal history
-retention, and machine cleanup are separate operations. Closing a session
-keeps its history. If `deleteAfterCloseMs` is configured on the session or
-profile, the root session owns the later deletion deadline; otherwise there
-is no automatic deletion deadline.
-
-Forked and delegated descendants share that retained root. Automatic deletion
-waits until the deadline and until the entire retained tree is closed. An
+Session closure, soft deletion, permanent deletion, blob collection, Temporal
+history retention, and machine cleanup are separate operations. Closing a session
+keeps its history. If the root session has a `deleteAfterCloseMs` policy,
+it owns the later soft-deletion deadline; otherwise there is no automatic
+deletion deadline. Setting deletion retention requires an Admin.
+
+Forked and delegated descendants share that retained root. Automatic soft
+deletion waits until the deadline and until the entire retained tree is closed. An
open descendant therefore prevents deletion of the tree. The session
retention reaper checks every five minutes and reports due roots, deletions,
open-tree skips, conflicts, and errors.
+Soft deletion hides sessions but retains events, checkpoints and blob roots.
+There is no automatic purge: a Platform admin must
+[permanently delete the retained history](../using-lightspeed/sessions-and-runs.md#close-and-retain-a-session)
+before its blob roots are released. Retention alone therefore does not reclaim
+that storage. It also does not remove workspaces, environments, backups or
+Temporal history.
+
Blob collection runs separately. One elected `sessions` process examines the
CAS catalog hourly for old blobs without durable holders. The default grace
is seven days since the last put or API admission of the reference. Reading a
diff --git a/docs/documentation/how-it-works/context-and-storage.md b/docs/documentation/how-it-works/context-and-storage.md
index fb6b52945..36e16f3f8 100644
--- a/docs/documentation/how-it-works/context-and-storage.md
+++ b/docs/documentation/how-it-works/context-and-storage.md
@@ -315,9 +315,9 @@ edge protects it. Removing a parent can release its children for a later pass.
An elected collector runs hourly with bounded scanning. The default grace is
seven days since the last put or API admission of an existing reference;
-reading content does not renew that grace. Session deletion releases its roots,
-while compaction leaves those roots intact because session history still
-records the original content. Reducing a model window and reclaiming disk
+reading content does not renew that grace. Permanent session deletion releases
+its roots; soft deletion retains them. Compaction also leaves roots intact
+because session history still records the original content. Reducing a model window and reclaiming disk
space are therefore separate operations.
Profiles borrow their content references rather than holding blobs indefinitely.
diff --git a/docs/documentation/using-lightspeed/bots-and-triggers.md b/docs/documentation/using-lightspeed/bots-and-triggers.md
index 5711e29ec..6cdf37e0d 100644
--- a/docs/documentation/using-lightspeed/bots-and-triggers.md
+++ b/docs/documentation/using-lightspeed/bots-and-triggers.md
@@ -237,8 +237,9 @@ thread gets its next conversation when another event needs it.
Under **Settings → Danger zone**, **Close bot** is terminal. It cancels work,
closes conversations and descendants, archives pending events, removes
schedules, and refuses new work while keeping the bot and its history.
-Deleting additionally removes the bot record, triggers, events, and
-conversations, and makes its ID available again.
+Deleting additionally removes the bot record, triggers and events, and makes
+its ID available again. Closed conversation history remains retained and follows
+the separate [session deletion lifecycle](sessions-and-runs.md#close-and-retain-a-session).
Profiles and environments remain independent resources. Closing or deleting a
bot or any of its sessions leaves its environments intact. Manage machine
diff --git a/docs/documentation/using-lightspeed/profiles-and-instructions.md b/docs/documentation/using-lightspeed/profiles-and-instructions.md
index 99c3b827e..8859381c7 100644
--- a/docs/documentation/using-lightspeed/profiles-and-instructions.md
+++ b/docs/documentation/using-lightspeed/profiles-and-instructions.md
@@ -190,8 +190,11 @@ See [Environments](../environments/overview.md) for setup and cleanup.
Metadata and retention settings supply defaults for newly created sessions.
Use metadata for organization, such as `project=acorn`, and retention to
-choose how long a closed session tree should remain stored. Neither supplies
-instructions to the agent.
+choose when a closed session tree is soft-deleted from ordinary views; history
+remains stored until a Platform admin purges it. Through Platform, only Admins
+can create sessions with a deletion schedule. Non-admin creation and bot
+conversations do not inherit profile deletion schedules. Neither metadata nor
+retention supplies instructions to the agent.
## If the setup does not take effect
diff --git a/docs/documentation/using-lightspeed/sessions-and-runs.md b/docs/documentation/using-lightspeed/sessions-and-runs.md
index e8c224b6f..e80b7e418 100644
--- a/docs/documentation/using-lightspeed/sessions-and-runs.md
+++ b/docs/documentation/using-lightspeed/sessions-and-runs.md
@@ -51,9 +51,9 @@ title menu, choose **Share with universe…**, and confirm with **Share**. This
also shares its sub-agents. Sharing cannot be undone.
Every member can then read the conversation, and Contributors and above can
-continue or control it. Sharing and deletion remain creator-or-Admin actions
-and require at least the Contributor role. Bot conversations are always
-shared; delegated sessions follow their root's visibility.
+continue runs, steer, cancel work, or decide tool approvals. Sharing requires
+the creator with at least the Contributor role, or an Admin. Bot conversations
+are always shared; delegated sessions follow their root's visibility.
Files written into an attached shared workspace remain visible through that
workspace even while the conversation is private. Direct core keys with the
@@ -308,12 +308,26 @@ Leaving a browser tab or quitting the CLI has no effect on session lifecycle.
accept more work or be reopened. Its retained history is still inspectable.
**Force close session** also cancels outstanding work, including queued runs.
-Closed ordinary sessions can be deleted. **Also delete forks and delegated
-children** includes their retention descendants, which must also be closed;
-configuration-only clones are separate. **Delete after close (days)** sets
-automatic retention, with a blank value keeping history until manual deletion.
-The root session owns this policy for its retention tree, so descendants do
-not independently choose how long that tree is kept.
+Deleting a closed session **soft-deletes** it: it disappears from ordinary
+session views for everyone, including Admins, while its history remains stored.
+**Also delete forks and delegated children** includes its retention descendants,
+which must also be closed; configuration-only clones are separate. Without
+that option, the session must have no retention descendants. Closing and
+deletion have different [role requirements](../access-and-security/private-and-shared-work.md#what-each-person-may-do).
+
+Admins can set **Delete after close (days)** to automatically soft-delete a
+closed session tree; a blank value keeps it until manual deletion. The root
+owns this policy for its retention tree, and deletion waits until all descendants
+are closed. This policy never permanently removes stored history.
+
+Only Platform admins can permanently delete retained session history. In the
+delete dialog, select **Also permanently delete retained history** to soft-delete
+and then purge. For already deleted sessions, open **Platform admin → Universes →
+Deleted sessions**, select sessions and choose **Purge selected…**, or use
+**Purge all…**, then confirm removal of their deleted history trees. There is
+no automatic purge or restore action.
+Permanent deletion through Platform is recorded in the
+[audit log](../access-and-security/overview.md#what-is-recorded-today).
Bot and channel conversations have a lifecycle controller. Their session
inspector identifies what manages them; use that controller's reset or close
diff --git a/docs/roadmap/p190-session-deletion-and-closing.md b/docs/roadmap/p190-session-deletion-and-closing.md
index 1e8370e14..21c27cb0f 100644
--- a/docs/roadmap/p190-session-deletion-and-closing.md
+++ b/docs/roadmap/p190-session-deletion-and-closing.md
@@ -39,8 +39,16 @@ remain intact until explicit permanent deletion. Soft-deleted rows prevent reuse
of their session IDs while retained.
Permanent deletion is a deployment-scoped operation, exposed only to platform
-admins through the Universes administration page. The admin selects a deleted
-session and confirms removal of it and its deleted history subtree. The operation
+admins through the Universes administration page or an unchecked-by-default
+“Also permanently delete retained history” option in the ordinary session delete
+dialog. Both paths use the same audited Platform purge endpoint. The inline path
+soft-deletes first, then purges; a failed purge leaves a retry available without
+repeating soft deletion. In the Universes modal, admins can select multiple
+deleted sessions or choose Purge all. Purge all collects every page before
+confirmation; newly deleted sessions arriving afterward are not added to that
+selection. Each purge uses the existing audit path, and partial failures can be
+retried. The admin confirms removal of the selected sessions and their deleted
+history subtrees. The operation
rejects any selected session that has not been soft-deleted. Repeating it after
successful removal returns no affected IDs. There is no automatic purge, grace
period, tombstone, restore API or restore UI.
@@ -98,6 +106,7 @@ as successful actions; empty purge retries do not duplicate successful purge log
- [x] Reuse Platform audit and add platform-admin inspection and explicit purge.
- [x] Align individual, bulk and demo lifecycle controls.
- [x] Complete regression coverage, regenerated contracts and component checks.
+- [x] Align existing user, access and operations guides with deletion and audit behavior.
Validation passed: workspace Clippy with warnings denied; API, PostgreSQL-store
and runtime unit tests; local PostgreSQL lifecycle and CAS-retention tests; the
diff --git a/platform/README.md b/platform/README.md
index fb8f11639..b0c88779e 100644
--- a/platform/README.md
+++ b/platform/README.md
@@ -122,9 +122,9 @@ for development.
**Revocation and history.** With OIDC, sessions expire absolutely after eight
hours by default and activity cannot renew them. Every authenticated request
checks current admission, suspension and permissions. **Users** supports
-suspension, reinstatement and signing out all sessions, and shows the latest
-100 durable access events. Membership changes apply on the next request;
-provider login preserves them. Core keys and already admitted work remain
+suspension, reinstatement and signing out all sessions. **Audit log** shows the
+latest 100 durable access and permanent session deletion events. Membership
+changes apply on the next request; provider login preserves them. Core keys and already admitted work remain
independent and require separate revocation or cancellation.
**Universes and roles.** A universe is an organization. Its members hold one of
@@ -135,7 +135,7 @@ four roles, least to most:
| viewer | read shared work and their own private sessions |
| contributor | also create sessions from defaults or an existing profile, continue runs, invoke bots, share their own sessions |
| operator | also customize sessions and run options, and configure profiles, bots, environments, MCP servers, credentials and channels |
-| admin | also manage members and keys, and read, share and delete any session |
+| admin | also manage members and keys, read and share any session, soft-delete closed sessions and set deletion retention |
A universe's creator is its admin, and a universe always keeps one. A platform
admin acts as an admin in every universe. Universe admins manage members on
@@ -155,8 +155,11 @@ a route makes for a member goes through one client
named profile, without setup overrides; managed creation and run configuration
overrides require an operator;
- for a method that names a session, unless the member is an admin, requires the
- session to be shared with the universe or created by the member; sharing and
- deleting need its creator;
+ session to be shared with the universe or created by the member; sharing
+ requires its creator;
+- limits closing to contributors' own unshared sessions, operators' own or
+ shared sessions, or any session for admins; soft deletion requires a closed
+ session and an admin, or an operator for shared sessions;
- narrows a member's session list to shared work and their own; and
- calls core with the Platform's deployment key, naming the universe and the
member as the actor.
@@ -164,6 +167,10 @@ a route makes for a member goes through one client
The Platform's own refusals are 403 and 404. Core refusing the Platform is a
server fault (500 or 502), since the member was already admitted. The web's
permission hints come from the same role and never replace these checks.
+Only platform admins can permanently delete soft-deleted session history,
+through the audited Platform purge route. See the
+[session lifecycle guide](../docs/documentation/using-lightspeed/sessions-and-runs.md#close-and-retain-a-session).
+
`configure_session` is separate from `control_session`: configuration, profile
application, metadata, and active-environment changes require an operator,
while ordinary run controls remain available to contributors. Profile creation
diff --git a/platform/backend/src/routes/session-lifecycle.test.ts b/platform/backend/src/routes/session-lifecycle.test.ts
index c5ff0a57a..b6cbfe3ce 100644
--- a/platform/backend/src/routes/session-lifecycle.test.ts
+++ b/platform/backend/src/routes/session-lifecycle.test.ts
@@ -80,3 +80,17 @@ it("does not duplicate purge audit when the runtime reports an already-removed s
expect((await f.call("/admin/universes/universe/sessions/s1/purge")).status).toBe(200);
expect(f.audits).not.toHaveBeenCalled();
});
+
+it("audits permanent deletion after the universe soft-delete step exactly once", async () => {
+ auth.platformAdmin = true;
+ const f = fixture();
+ expect((await f.call("/universes/universe/sessions/s1?cascade=true", "DELETE")).status).toBe(200);
+ expect(f.audits).not.toHaveBeenCalled();
+ expect((await f.call("/admin/universes/universe/sessions/s1/purge")).status).toBe(200);
+ expect(f.rpcCalls).toEqual(["session/delete", "deployment/sessions/purge"]);
+ expect(f.audits).toHaveBeenCalledOnce();
+ expect(f.audits).toHaveBeenCalledWith(expect.objectContaining({
+ actorId: "member", universeId: "universe", targetId: "s1", action: "session.purge",
+ details: { deletedSessionIds: '["s1","child"]' }, outcome: "success",
+ }));
+});
diff --git a/platform/web/src/components/admin/deleted-sessions.test.tsx b/platform/web/src/components/admin/deleted-sessions.test.tsx
index 65eb9a9f7..5025cfa68 100644
--- a/platform/web/src/components/admin/deleted-sessions.test.tsx
+++ b/platform/web/src/components/admin/deleted-sessions.test.tsx
@@ -10,15 +10,31 @@ vi.mock("@/api", () => ({ api: mocks.api }));
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
+let remaining: string[];
+let pageSize: number;
+let failed: Set;
+let cascades: Record;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("PointerEvent", MouseEvent);
client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
- container = document.createElement("div");
- document.body.append(container);
- root = createRoot(container);
- mocks.api.mockReset().mockResolvedValue({ sessions: [{ sessionId: "s1", displayName: "Deleted work", deletedAtMs: 1 }], nextAfter: null });
+ container = document.createElement("div"); document.body.append(container); root = createRoot(container);
+ remaining = ["s1", "s2"];
+ pageSize = 100;
+ failed = new Set(); cascades = {};
+ mocks.api.mockReset().mockImplementation(async (method: string, path: string) => {
+ if (method === "GET") {
+ const after = new URL(path, "http://test").searchParams.get("after");
+ const ids = remaining.filter((id) => !after || id > after).slice(0, pageSize + 1);
+ return { sessions: ids.slice(0, pageSize).map((id) => ({ sessionId: id, displayName: `Deleted ${id}`, deletedAtMs: 1 })), nextAfter: ids.length > pageSize ? ids[pageSize - 1] : null };
+ }
+ const id = decodeURIComponent(path.split("/").at(-2)!);
+ if (failed.has(id)) throw new Error(`Could not purge ${id}`);
+ const removed = remaining.filter((candidate) => (cascades[id] ?? [id]).includes(candidate));
+ remaining = remaining.filter((candidate) => !removed.includes(candidate));
+ return { deletedSessionIds: removed };
+ });
});
afterEach(async () => {
await act(async () => root.unmount());
@@ -28,26 +44,93 @@ async function settle() {
for (let step = 0; step < 4; step++) await act(async () => { await vi.advanceTimersByTimeAsync(5); });
}
const button = (label: string) => [...document.body.querySelectorAll("button")].find((node) => node.textContent === label)!;
-async function show() {
- await act(async () => root.render( undefined} />));
+const purgeCalls = () => mocks.api.mock.calls.filter(([method]) => method === "POST");
+async function show(id = "u") {
+ await act(async () => root.render( undefined} />));
await settle();
}
-it("lists deleted sessions and requires confirmation before permanent deletion", async () => {
+async function click(label: string) {
+ await act(async () => button(label).click());
+ await settle();
+}
+async function select(id: string) {
+ await act(async () => document.querySelector(`[aria-label="Select session ${id}"]`)!.click());
+}
+it("purges only selected sessions across pages after a separate confirmation", async () => {
+ remaining = ["s1", "s2", "s3"]; pageSize = 2;
await show();
- expect(mocks.api).toHaveBeenCalledWith("GET", "/api/v1/admin/universes/u/deleted-sessions");
- await act(async () => button("Permanently delete…").click());
+ expect(button("Purge selected…").disabled).toBe(true);
+ await select("s1");
+ await click("Load more");
+ await select("s3");
+ await click("Purge selected…");
+ expect(document.body.textContent).toContain("2 selected sessions");
expect(document.body.textContent).toContain("This cannot be undone");
- expect(mocks.api.mock.calls.some(([method]) => method === "POST")).toBe(false);
- await act(async () => button("Permanently delete").click());
- await settle();
- expect(mocks.api).toHaveBeenCalledWith("POST", "/api/v1/admin/universes/u/sessions/s1/purge", {});
+ expect(purgeCalls()).toEqual([]);
+ await click("Purge selected");
+ expect(purgeCalls()).toEqual([
+ ["POST", "/api/v1/admin/universes/u/sessions/s1/purge", {}],
+ ["POST", "/api/v1/admin/universes/u/sessions/s3/purge", {}],
+ ]);
+ expect(remaining).toEqual(["s2"]);
});
-it("preserves the confirmation and shows failed permanent deletion", async () => {
+it("purge all captures every page before confirmation and excludes later arrivals", async () => {
+ pageSize = 1;
await show();
- mocks.api.mockRejectedValue(new Error("session must be soft-deleted first"));
- await act(async () => button("Permanently delete…").click());
- await act(async () => button("Permanently delete").click());
- await settle();
- expect(document.querySelector('[role="alert"]')?.textContent).toBe("session must be soft-deleted first");
- expect(button("Permanently delete")).toBeDefined();
+ expect(document.querySelector('[aria-label="Select session s2"]')).toBeNull();
+ await click("Purge all…");
+ expect(mocks.api).toHaveBeenCalledWith("GET", "/api/v1/admin/universes/u/deleted-sessions?after=s1");
+ expect(document.body.textContent).toContain("2 selected sessions");
+ expect(purgeCalls()).toEqual([]);
+ remaining.push("s3");
+ await click("Purge all");
+ expect(purgeCalls().map(([, path]) => path)).toEqual([
+ "/api/v1/admin/universes/u/sessions/s1/purge", "/api/v1/admin/universes/u/sessions/s2/purge",
+ ]);
+ expect(remaining).toEqual(["s3"]);
+});
+it("does not offer a partial purge-all selection when collecting a page fails", async () => {
+ pageSize = 1;
+ await show();
+ mocks.api.mockResolvedValueOnce({ sessions: [{ sessionId: "s1" }], nextAfter: "s1" }).mockRejectedValueOnce(new Error("Page unavailable"));
+ await click("Purge all…");
+ expect(document.querySelector('[role="alert"]')?.textContent).toBe("Page unavailable");
+ expect(button("Purge all")).toBeUndefined();
+ expect(purgeCalls()).toEqual([]);
+});
+it("retries only failures after partially successful deletion", async () => {
+ failed.add("s2");
+ await show();
+ await click("Purge all…");
+ await click("Purge all");
+ expect(document.querySelector('[role="status"]')?.textContent).toContain("Permanently deleted 1 session. 1 failed");
+ expect(document.querySelector('[role="alert"]')?.textContent).toContain("Could not purge s2");
+ failed.clear();
+ await click("Retry failed");
+ expect(purgeCalls().map(([, path]) => path)).toEqual([
+ "/api/v1/admin/universes/u/sessions/s1/purge", "/api/v1/admin/universes/u/sessions/s2/purge", "/api/v1/admin/universes/u/sessions/s2/purge",
+ ]);
+ expect(remaining).toEqual([]);
+});
+it("skips selected descendants already removed by a parent's purge", async () => {
+ cascades.s1 = ["s1", "s2"];
+ await show();
+ await click("Purge all…");
+ await click("Purge all");
+ expect(purgeCalls()).toHaveLength(1);
+ expect(document.querySelector('[role="status"]')?.textContent).toBe("Permanently deleted 2 sessions.");
+});
+it("resets the selection when switching universes", async () => {
+ await show(); await select("s1");
+ expect(button("Purge selected…").disabled).toBe(false);
+ await show("another-universe");
+ expect(button("Purge selected…").disabled).toBe(true);
+ expect(purgeCalls()).toEqual([]);
+});
+it("disables both purge actions when no deleted sessions exist", async () => {
+ remaining = [];
+ await show();
+ expect(document.body.textContent).toContain("No deleted sessions.");
+ expect(button("Purge all…").disabled).toBe(true);
+ expect(button("Purge selected…").disabled).toBe(true);
});
diff --git a/platform/web/src/components/admin/deleted-sessions.tsx b/platform/web/src/components/admin/deleted-sessions.tsx
index 03a0a5586..77dbfccc9 100644
--- a/platform/web/src/components/admin/deleted-sessions.tsx
+++ b/platform/web/src/components/admin/deleted-sessions.tsx
@@ -1,55 +1,127 @@
-import type { DeploymentDeletedSessionsListResponse, DeletedSessionView } from "@lightspeed-ai/sdk";
+import type { DeploymentDeletedSessionsListResponse, DeploymentSessionPurgeResponse } from "@lightspeed-ai/sdk";
import { useState } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/api";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { LoadingNote } from "@/components/page";
+type Universe = { id: string; name: string };
+type Selection = { ids: string[]; all: boolean };
+
/** Mounted only in Platform administration, never in the universe browser. */
-export function DeletedSessionsDialog({ universe, onClose }: { universe: { id: string; name: string } | null; onClose: () => void }) {
+export function DeletedSessionsDialog({ universe, onClose }: { universe: Universe | null; onClose: () => void }) {
+ return universe ? : null;
+}
+
+function DeletedSessionsContent({ universe, onClose }: { universe: Universe; onClose: () => void }) {
const queryClient = useQueryClient();
- const [selected, setSelected] = useState(null);
- const base = `/api/v1/admin/universes/${encodeURIComponent(universe?.id ?? "")}`;
+ const [selected, setSelected] = useState>(() => new Set());
+ const [confirmation, setConfirmation] = useState(null);
+ const [notice, setNotice] = useState(null);
+ const base = `/api/v1/admin/universes/${encodeURIComponent(universe.id)}`;
+ const readPage = (after?: string) => api("GET", `${base}/deleted-sessions${after ? `?after=${encodeURIComponent(after)}` : ""}`);
const sessions = useInfiniteQuery({
- queryKey: ["admin", "deleted-sessions", universe?.id], enabled: !!universe,
+ queryKey: ["admin", "deleted-sessions", universe.id],
initialPageParam: undefined as string | undefined,
- queryFn: ({ pageParam }) => api("GET", `${base}/deleted-sessions${pageParam ? `?after=${encodeURIComponent(pageParam)}` : ""}`),
+ queryFn: ({ pageParam }) => readPage(pageParam),
getNextPageParam: (page) => page.nextAfter ?? undefined,
});
+ const rows = sessions.data?.pages.flatMap((page) => page.sessions) ?? [];
+ const selectedIds = rows.filter((session) => selected.has(session.sessionId)).map((session) => session.sessionId);
+ const allLoadedSelected = rows.length > 0 && rows.every((session) => selected.has(session.sessionId));
+ const refresh = () => {
+ void queryClient.invalidateQueries({ queryKey: ["admin", "deleted-sessions", universe.id] });
+ void queryClient.invalidateQueries({ queryKey: ["admin", "audit"] });
+ };
+ const prepareAll = useMutation({
+ mutationFn: async () => {
+ // Capture all pages before confirmation. Later arrivals are not added to
+ // the confirmed selection while permanent deletion is in progress.
+ const ids = new Set();
+ let after: string | undefined;
+ do {
+ const page = await readPage(after);
+ for (const session of page.sessions) ids.add(session.sessionId);
+ after = page.nextAfter ?? undefined;
+ } while (after);
+ return [...ids];
+ },
+ onSuccess: (ids) => {
+ if (ids.length) { setNotice(null); setConfirmation({ ids, all: true }); }
+ else { setNotice("No deleted sessions remain."); refresh(); }
+ },
+ });
const purge = useMutation({
- mutationFn: (sessionId: string) => api("POST", `${base}/sessions/${encodeURIComponent(sessionId)}/purge`, {}),
- onSuccess: () => {
- setSelected(null);
- void queryClient.invalidateQueries({ queryKey: ["admin", "deleted-sessions", universe?.id] });
- void queryClient.invalidateQueries({ queryKey: ["admin", "audit"] });
+ mutationFn: async (ids: string[]) => {
+ const removed = new Set();
+ const failures = new Map();
+ for (const id of ids) {
+ if (removed.has(id)) continue; // A previously purged parent included it.
+ try {
+ const result = await api("POST", `${base}/sessions/${encodeURIComponent(id)}/purge`, {});
+ for (const removedId of result.deletedSessionIds) removed.add(removedId);
+ } catch (error) {
+ failures.set(id, error instanceof Error ? error.message : String(error));
+ }
+ }
+ // A later parent's purge may have removed an earlier failed child.
+ for (const id of removed) failures.delete(id);
+ return { removed: removed.size, failures: [...failures].map(([id, error]) => ({ id, error })) };
+ },
+ onSuccess: ({ removed, failures }) => {
+ setNotice(`Permanently deleted ${removed} ${removed === 1 ? "session" : "sessions"}.${failures.length ? ` ${failures.length} failed; retry the remaining selection.` : ""}`);
+ setSelected(new Set(failures.map(({ id }) => id)));
+ setConfirmation(failures.length ? { ids: failures.map(({ id }) => id), all: false } : null);
+ refresh();
},
});
- const close = () => { if (!purge.isPending) { setSelected(null); purge.reset(); onClose(); } };
- return
- {
- setDeleteOpen(open);
- if (open) {
- setDeleteError(null);
- setDeleteCascade(false);
- }
- }}
- >
-
-
- Delete this session?
-
- This hides the session and its history from all universe members, including admins. History is retained until a platform admin permanently deletes it.
- A session with history forks or delegated children cannot be deleted
- unless cascade is enabled.
-
-
-
- {deleteError &&
Date: Tue, 6 Oct 2026 17:58:18 +0200
Subject: [PATCH 7/8] soft delete optional
---
clients/typescript/schema/api.schema.json | 8 +-
clients/typescript/src/generated/methods.ts | 12 +-
clients/typescript/src/generated/types.ts | 9 +-
crates/api/contract/api-reference.md | 4 +-
crates/api/contract/api.schema.json | 8 +-
crates/api/contract/methods.json | 4 +-
crates/api/contract/openrpc.json | 12 +-
crates/api/src/rpc.rs | 4 +-
crates/api/src/sessions.rs | 8 +-
crates/api/tests/schema_artifacts.rs | 20 +++
crates/cli/src/session_cli.rs | 14 +-
crates/cli/tests/resource_commands.rs | 26 ++++
crates/store-pg/src/session.rs | 45 +++---
.../store-pg/tests/session_lifecycle_live.rs | 139 +++++++++++++++++-
crates/store-pg/tests/store_pg_live.rs | 23 ++-
.../src/gateway/service/mod.rs | 1 +
.../temporal-runtime/src/session_deletion.rs | 4 +-
crates/temporal-runtime/src/worker/reaper.rs | 3 +-
.../temporal-runtime/tests/profiles_live.rs | 1 +
.../temporal-runtime/tests/sessions_live.rs | 40 ++++-
.../api-keys-and-service-access.md | 11 +-
docs/documentation/deployment/operations.md | 13 +-
.../using-lightspeed/sessions-and-runs.md | 15 +-
.../p190-session-deletion-and-closing.md | 38 +++--
platform/backend/src/routes/gateway.ts | 1 +
.../src/routes/session-lifecycle.test.ts | 11 +-
platform/backend/src/runtime-client.test.ts | 3 +-
platform/backend/src/runtime-client.ts | 5 +-
.../configurator-mcp/src/generated/tools.ts | 12 +-
.../src/components/markdown-view-toggle.tsx | 19 +--
platform/web/src/pages/BlobPage.test.tsx | 22 ++-
.../pages/WorkspacesPage.blob-view.test.tsx | 32 +++-
platform/web/src/pages/WorkspacesPage.tsx | 17 ++-
33 files changed, 459 insertions(+), 125 deletions(-)
diff --git a/clients/typescript/schema/api.schema.json b/clients/typescript/schema/api.schema.json
index c2e65e0e5..4e5cf19ee 100644
--- a/clients/typescript/schema/api.schema.json
+++ b/clients/typescript/schema/api.schema.json
@@ -14029,6 +14029,10 @@
"sharedOnly": {
"description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
"type": "boolean"
+ },
+ "softDelete": {
+ "description": "Hide sessions while retaining records, events, checkpoints and blob roots.\nFalse (the default) permanently deletes them without prior soft deletion.",
+ "type": "boolean"
}
},
"required": [
@@ -16321,7 +16325,7 @@
"additionalProperties": false,
"properties": {
"deleteAfterCloseMs": {
- "description": "Positive duration enables automatic tree deletion; null disables it.",
+ "description": "Positive duration enables automatic tree soft deletion; null disables it.",
"format": "uint64",
"minimum": 1,
"type": [
@@ -16426,7 +16430,7 @@
]
},
"deleteAfterCloseMs": {
- "description": "Root-owned automatic deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
+ "description": "Root-owned automatic soft deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
"format": "uint64",
"maximum": 3153600000000,
"minimum": 1,
diff --git a/clients/typescript/src/generated/methods.ts b/clients/typescript/src/generated/methods.ts
index de8c942c0..e9e78e345 100644
--- a/clients/typescript/src/generated/methods.ts
+++ b/clients/typescript/src/generated/methods.ts
@@ -206,7 +206,7 @@ export const METHOD_INFO = {
scope: "universe",
access: {"action":"set_session_retention","kind":"universe"},
summary: "Replace session retention",
- description: "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
+ description: "Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history.",
},
"session/close": {
scope: "universe",
@@ -218,7 +218,7 @@ export const METHOD_INFO = {
scope: "universe",
access: {"action":"delete_session","kind":"universe"},
summary: "Delete closed sessions",
- description: "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
+ description: "Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included.",
},
"session/share": {
scope: "universe",
@@ -1087,7 +1087,7 @@ export interface MethodMap {
/**
* Replace session retention
*
- * Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.
+ * Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history.
*/
"session/retention/put": {
params: Api.SessionRetentionPutParams;
@@ -1105,7 +1105,7 @@ export interface MethodMap {
/**
* Delete closed sessions
*
- * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.
+ * Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included.
*/
"session/delete": {
params: Api.SessionDeleteParams;
@@ -2348,7 +2348,7 @@ export const rpc = {
/**
* Replace session retention
*
- * Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.
+ * Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history.
*/
sessionRetentionPut(client: RpcCaller, params: Api.SessionRetentionPutParams): Promise {
return client.call("session/retention/put", params);
@@ -2364,7 +2364,7 @@ export const rpc = {
/**
* Delete closed sessions
*
- * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.
+ * Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included.
*/
sessionDelete(client: RpcCaller, params: Api.SessionDeleteParams): Promise {
return client.call("session/delete", params);
diff --git a/clients/typescript/src/generated/types.ts b/clients/typescript/src/generated/types.ts
index 7f6e30d04..42759ea1c 100644
--- a/clients/typescript/src/generated/types.ts
+++ b/clients/typescript/src/generated/types.ts
@@ -8108,6 +8108,11 @@ export interface SessionDeleteParams {
* Delegating services use this guard for operators deleting shared work.
*/
sharedOnly?: boolean;
+ /**
+ * Hide sessions while retaining records, events, checkpoints and blob roots.
+ * False (the default) permanently deletes them without prior soft deletion.
+ */
+ softDelete?: boolean;
}
/**
* This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema
@@ -8244,7 +8249,7 @@ export interface SessionRenameParams {
*/
export interface SessionRetentionPutParams {
/**
- * Positive duration enables automatic tree deletion; null disables it.
+ * Positive duration enables automatic tree soft deletion; null disables it.
*/
deleteAfterCloseMs: number | null;
sessionId: string;
@@ -8268,7 +8273,7 @@ export interface SessionStartParams {
access?: AccessInput | null;
config?: SessionConfig | null;
/**
- * Root-owned automatic deletion measured from close. Absent inherits a
+ * Root-owned automatic soft deletion measured from close. Absent inherits a
* profile default, explicit null keeps the tree, and a duration overrides
* the profile.
*/
diff --git a/crates/api/contract/api-reference.md b/crates/api/contract/api-reference.md
index edf141f93..d3a9902cc 100644
--- a/crates/api/contract/api-reference.md
+++ b/crates/api/contract/api-reference.md
@@ -133,7 +133,7 @@ Replaces the complete descriptive key/value map (bounded like session/start); an
**Replace session retention**
-Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.
+Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history.
- Access: `{"kind":"universe","action":"set_session_retention"}`
- Group: `session/delete`
@@ -159,7 +159,7 @@ Closes an idle session and detaches its environment bindings. Force mode cancels
**Delete closed sessions**
-Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.
+Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included.
- Access: `{"kind":"universe","action":"delete_session"}`
- Group: `session/delete`
diff --git a/crates/api/contract/api.schema.json b/crates/api/contract/api.schema.json
index c2e65e0e5..4e5cf19ee 100644
--- a/crates/api/contract/api.schema.json
+++ b/crates/api/contract/api.schema.json
@@ -14029,6 +14029,10 @@
"sharedOnly": {
"description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
"type": "boolean"
+ },
+ "softDelete": {
+ "description": "Hide sessions while retaining records, events, checkpoints and blob roots.\nFalse (the default) permanently deletes them without prior soft deletion.",
+ "type": "boolean"
}
},
"required": [
@@ -16321,7 +16325,7 @@
"additionalProperties": false,
"properties": {
"deleteAfterCloseMs": {
- "description": "Positive duration enables automatic tree deletion; null disables it.",
+ "description": "Positive duration enables automatic tree soft deletion; null disables it.",
"format": "uint64",
"minimum": 1,
"type": [
@@ -16426,7 +16430,7 @@
]
},
"deleteAfterCloseMs": {
- "description": "Root-owned automatic deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
+ "description": "Root-owned automatic soft deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
"format": "uint64",
"maximum": 3153600000000,
"minimum": 1,
diff --git a/crates/api/contract/methods.json b/crates/api/contract/methods.json
index 090340037..ad1d9c68d 100644
--- a/crates/api/contract/methods.json
+++ b/crates/api/contract/methods.json
@@ -229,7 +229,7 @@
"action": "set_session_retention",
"kind": "universe"
},
- "description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
+ "description": "Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history.",
"group": "session/delete",
"method": "session/retention/put",
"params": {
@@ -279,7 +279,7 @@
"action": "delete_session",
"kind": "universe"
},
- "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
+ "description": "Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included.",
"group": "session/delete",
"method": "session/delete",
"params": {
diff --git a/crates/api/contract/openrpc.json b/crates/api/contract/openrpc.json
index 1980710ed..4905882f3 100644
--- a/crates/api/contract/openrpc.json
+++ b/crates/api/contract/openrpc.json
@@ -14029,6 +14029,10 @@
"sharedOnly": {
"description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
"type": "boolean"
+ },
+ "softDelete": {
+ "description": "Hide sessions while retaining records, events, checkpoints and blob roots.\nFalse (the default) permanently deletes them without prior soft deletion.",
+ "type": "boolean"
}
},
"required": [
@@ -16321,7 +16325,7 @@
"additionalProperties": false,
"properties": {
"deleteAfterCloseMs": {
- "description": "Positive duration enables automatic tree deletion; null disables it.",
+ "description": "Positive duration enables automatic tree soft deletion; null disables it.",
"format": "uint64",
"minimum": 1,
"type": [
@@ -16426,7 +16430,7 @@
]
},
"deleteAfterCloseMs": {
- "description": "Root-owned automatic deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
+ "description": "Root-owned automatic soft deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
"format": "uint64",
"maximum": 3153600000000,
"minimum": 1,
@@ -18989,7 +18993,7 @@
"x-lightspeed-target": "sessionId"
},
{
- "description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
+ "description": "Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history.",
"name": "session/retention/put",
"paramStructure": "by-name",
"params": [
@@ -19045,7 +19049,7 @@
"x-lightspeed-target": "sessionId"
},
{
- "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
+ "description": "Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included.",
"name": "session/delete",
"paramStructure": "by-name",
"params": [
diff --git a/crates/api/src/rpc.rs b/crates/api/src/rpc.rs
index b3fbda63a..08c2d47e5 100644
--- a/crates/api/src/rpc.rs
+++ b/crates/api/src/rpc.rs
@@ -351,11 +351,11 @@ api_methods! {
METHOD_SESSION_METADATA_PUT => put_session_metadata(SessionMetadataPutParams) -> SessionMetadataPutResponse =>
["Replace session metadata", "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched."], access: MethodAccess::Universe(UniverseAction::ConfigureSession),
METHOD_SESSION_RETENTION_PUT => put_session_retention(SessionRetentionPutParams) -> SessionRetentionPutResponse =>
- ["Replace session retention", "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it."], access: MethodAccess::Universe(UniverseAction::SetSessionRetention),
+ ["Replace session retention", "Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history."], access: MethodAccess::Universe(UniverseAction::SetSessionRetention),
METHOD_SESSION_CLOSE => close_session(SessionCloseParams) -> SessionCloseResponse =>
["Close a session", "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable."], access: MethodAccess::Universe(UniverseAction::CloseSession),
METHOD_SESSION_DELETE => delete_session(SessionDeleteParams) -> SessionDeleteResponse =>
- ["Delete closed sessions", "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
+ ["Delete closed sessions", "Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession),
METHOD_SESSION_SHARE => share_session(SessionShareParams) -> SessionShareResponse =>
["Share a session with the universe", "Moves an unshared root session to universe visibility, one way; its delegated children follow it. Refused on a bot's session, a delegated child, and a session already shared. Core applies it for any caller of the method; who may share is the caller's gate's decision."], access: MethodAccess::Universe(UniverseAction::ShareSession),
METHOD_SESSION_EVENTS_READ => read_session_events(SessionEventsReadParams) -> SessionEventsReadResponse =>
diff --git a/crates/api/src/sessions.rs b/crates/api/src/sessions.rs
index a06ff3043..2d91e861a 100644
--- a/crates/api/src/sessions.rs
+++ b/crates/api/src/sessions.rs
@@ -40,7 +40,7 @@ pub struct SessionStartParams {
pub config: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option,
- /// Root-owned automatic deletion measured from close. Absent inherits a
+ /// Root-owned automatic soft deletion measured from close. Absent inherits a
/// profile default, explicit null keeps the tree, and a duration overrides
/// the profile.
#[serde(
@@ -1150,7 +1150,7 @@ pub struct SessionMetadataPutResponse {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SessionRetentionPutParams {
pub session_id: SessionId,
- /// Positive duration enables automatic tree deletion; null disables it.
+ /// Positive duration enables automatic tree soft deletion; null disables it.
#[serde(deserialize_with = "deserialize_required_delete_after_close_ms")]
#[schemars(
required,
@@ -1188,6 +1188,10 @@ pub struct SessionRetentionPutResponse {
#[serde(rename_all = "camelCase")]
pub struct SessionDeleteParams {
pub session_id: SessionId,
+ /// Hide sessions while retaining records, events, checkpoints and blob roots.
+ /// False (the default) permanently deletes them without prior soft deletion.
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+ pub soft_delete: bool,
/// Delete history forks and delegated descendants too. False requires the
/// target to be a closed retention-tree leaf.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
diff --git a/crates/api/tests/schema_artifacts.rs b/crates/api/tests/schema_artifacts.rs
index 6c0bf830d..cdadfef4a 100644
--- a/crates/api/tests/schema_artifacts.rs
+++ b/crates/api/tests/schema_artifacts.rs
@@ -15,6 +15,26 @@ fn schemas_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("contract")
}
+#[test]
+fn session_deletion_defaults_to_permanent_and_accepts_explicit_soft_deletion() {
+ let bundle = api::export_schemas().schema_bundle;
+ for soft_delete in [None, Some(false), Some(true)] {
+ let mut request = json!({"sessionId": "closed-session"});
+ if let Some(value) = soft_delete {
+ request["softDelete"] = json!(value);
+ }
+ assert_validates(&bundle, "SessionDeleteParams", &request);
+ let params: api::SessionDeleteParams = serde_json::from_value(request).unwrap();
+ assert_eq!(params.soft_delete, soft_delete.unwrap_or(false));
+ let serialized = serde_json::to_value(params).unwrap();
+ if soft_delete == Some(true) {
+ assert_eq!(serialized["softDelete"], true);
+ } else {
+ assert!(serialized.get("softDelete").is_none());
+ }
+ }
+}
+
fn committed(name: &str) -> Value {
let path = schemas_dir().join(name);
let text = fs::read_to_string(&path).unwrap_or_else(|error| {
diff --git a/crates/cli/src/session_cli.rs b/crates/cli/src/session_cli.rs
index 33bbbb763..5576b1d2d 100644
--- a/crates/cli/src/session_cli.rs
+++ b/crates/cli/src/session_cli.rs
@@ -48,12 +48,12 @@ enum SessionCommand {
Metadata(MetadataCommandArgs),
/// List active context entries, or replace ones the provider rejects.
Context(ContextCommandArgs),
- /// Set or clear automatic deletion for a retention root.
+ /// Set or clear automatic soft deletion for a retention root.
Retention(RetentionArgs),
/// Close one session by id, or every open session matching a filter.
Close(CloseArgs),
- /// Delete one closed session by id, or every closed session matching a
- /// filter; open matches are skipped.
+ /// Permanently delete one closed session or closed filter matches;
+ /// --soft-delete retains history, and open matches are skipped.
Delete(DeleteArgs),
}
@@ -104,7 +104,7 @@ struct StartArgs {
profile: Option,
#[command(flatten)]
metadata: MetadataPairs,
- /// Delete this retention tree after the root closes (for example 30m,
+ /// Soft-delete this retention tree after the root closes (for example 30m,
/// 24h, or 7d).
#[arg(long = "delete-after", value_parser = parse_duration_ms)]
delete_after_close_ms: Option,
@@ -198,7 +198,7 @@ struct RetentionArgs {
#[command(flatten)]
common: CommonArgs,
session_id: String,
- /// Delete the tree this long after its root closes (for example 24h).
+ /// Soft-delete the tree this long after its root closes (for example 24h).
#[arg(long = "delete-after", value_parser = parse_duration_ms, conflicts_with = "off")]
delete_after_close_ms: Option,
/// Disable automatic deletion.
@@ -232,6 +232,9 @@ struct DeleteArgs {
/// Also delete history forks and delegated child sessions.
#[arg(long)]
cascade: bool,
+ /// Hide sessions and retain their history instead of permanently deleting it.
+ #[arg(long)]
+ soft_delete: bool,
}
pub(crate) async fn handle(args: SessionArgs) -> Result<()> {
@@ -549,6 +552,7 @@ async fn delete(args: DeleteArgs) -> Result<()> {
match client
.delete_session(api::SessionDeleteParams {
shared_only: false,
+ soft_delete: args.soft_delete,
session_id: session_id.clone(),
cascade: args.cascade,
})
diff --git a/crates/cli/tests/resource_commands.rs b/crates/cli/tests/resource_commands.rs
index 3fe1e2859..182c2f60c 100644
--- a/crates/cli/tests/resource_commands.rs
+++ b/crates/cli/tests/resource_commands.rs
@@ -148,6 +148,32 @@ fn session_summary(id: &str) -> Value {
json!({"id":id,"displayName":"Review changes","lifecycleStatus":"open","activity":"idle","retention":{"rootSessionId":id},"managed":false,"access":{"visibility":"restricted"},"createdAtMs":1,"updatedAtMs":1})
}
+#[test]
+fn session_delete_is_permanent_unless_soft_delete_is_requested() {
+ for soft_delete in [false, true] {
+ let runtime = Runtime::start(move |method, params| {
+ assert_eq!(method, "session/delete");
+ assert_eq!(params["sessionId"], "s1");
+ assert_eq!(params["cascade"], true);
+ assert_eq!(
+ params
+ .get("softDelete")
+ .and_then(Value::as_bool)
+ .unwrap_or(false),
+ soft_delete
+ );
+ let mut summary = session_summary("s1");
+ summary["lifecycleStatus"] = json!("closed");
+ json!({"session": summary, "deletedSessionCount": 1})
+ });
+ let mut args = vec!["session", "delete", "s1", "--cascade"];
+ if soft_delete {
+ args.push("--soft-delete");
+ }
+ runtime.success(&args);
+ }
+}
+
#[test]
fn chat_list_requests_only_ten_unmanaged_roots_and_shows_status_without_starting_chat() {
let runtime = Runtime::start(|method, params| {
diff --git a/crates/store-pg/src/session.rs b/crates/store-pg/src/session.rs
index 54d1ff83a..a2cfa49b5 100644
--- a/crates/store-pg/src/session.rs
+++ b/crates/store-pg/src/session.rs
@@ -850,7 +850,7 @@ impl SessionStore for PgStore {
&self,
request: DeleteClosedSessions,
) -> Result {
- self.delete_closed_sessions_with_visibility(request, false)
+ self.delete_closed_sessions_with_options(request, false, false)
.await
}
@@ -1790,7 +1790,7 @@ impl PgStore {
}
}
-/// Permanent deletion is separate from ordinary session storage operations.
+/// Administrative purge requires prior soft deletion.
#[derive(Debug, thiserror::Error)]
pub enum PurgeSessionError {
#[error("session {session_id} must be soft-deleted before permanent deletion")]
@@ -1851,9 +1851,11 @@ impl PgStore {
}
impl PgStore {
- pub async fn delete_closed_sessions_with_visibility(
+ /// Both modes atomically enforce lifecycle, cascade and audience constraints.
+ pub async fn delete_closed_sessions_with_options(
&self,
request: DeleteClosedSessions,
+ soft_delete: bool,
shared_only: bool,
) -> Result {
let target_snapshot = self
@@ -1907,13 +1909,14 @@ impl PgStore {
ON (child.source_seq IS NOT NULL
AND child.source_session_id = parent.session_id)
OR child.origin_parent_session_id = parent.session_id
- WHERE child.universe_id = $1 AND child.deleted_at_ms IS NULL
+ WHERE child.universe_id = $1 AND (NOT $3 OR child.deleted_at_ms IS NULL)
)
SELECT session_id FROM tree ORDER BY session_id
"#,
)
.bind(self.config.universe_id)
.bind(request.session_id.as_str())
+ .bind(soft_delete)
.fetch_all(&mut *tx)
.await
.map_err(|error| session_sql_error("list session retention subtree", error))?;
@@ -1931,7 +1934,7 @@ impl PgStore {
r#"
SELECT {SESSION_COLUMNS}, {SESSION_ACTIVITY}
FROM sessions
- WHERE universe_id = $1 AND session_id = ANY($2) AND deleted_at_ms IS NULL
+ WHERE universe_id = $1 AND session_id = ANY($2) AND (NOT $3 OR deleted_at_ms IS NULL)
ORDER BY session_id
FOR UPDATE
"#,
@@ -1939,6 +1942,7 @@ impl PgStore {
let rows = sqlx::query(sqlx::AssertSqlSafe(query))
.bind(self.config.universe_id)
.bind(&selected_ids)
+ .bind(soft_delete)
.fetch_all(&mut *tx)
.await
.map_err(|error| session_sql_error("lock session retention subtree", error))?;
@@ -1985,19 +1989,24 @@ impl PgStore {
sqlx::query("UPDATE sessions SET source_session_id = NULL WHERE universe_id=$1 AND source_session_id=ANY($2) AND source_seq IS NULL")
.bind(self.config.universe_id).bind(&selected_ids).execute(&mut *tx).await
.map_err(|error| session_sql_error("detach deleted clone sources", error))?;
- let now = crate::shared::unix_now_ms();
- sqlx::query(
- r#"
- UPDATE sessions SET deleted_at_ms = $3
- WHERE universe_id = $1 AND session_id = ANY($2) AND deleted_at_ms IS NULL
- "#,
- )
- .bind(self.config.universe_id)
- .bind(&selected_ids)
- .bind(now)
- .execute(&mut *tx)
- .await
- .map_err(|error| session_sql_error("delete session subtree", error))?;
+ if soft_delete {
+ sqlx::query(
+ "UPDATE sessions SET deleted_at_ms = $3 WHERE universe_id = $1 AND session_id = ANY($2) AND deleted_at_ms IS NULL",
+ )
+ .bind(self.config.universe_id)
+ .bind(&selected_ids)
+ .bind(crate::shared::unix_now_ms())
+ .execute(&mut *tx)
+ .await
+ .map_err(|error| session_sql_error("soft delete session subtree", error))?;
+ } else {
+ sqlx::query("DELETE FROM sessions WHERE universe_id = $1 AND session_id = ANY($2)")
+ .bind(self.config.universe_id)
+ .bind(&selected_ids)
+ .execute(&mut *tx)
+ .await
+ .map_err(|error| session_sql_error("permanently delete session subtree", error))?;
+ }
tx.commit()
.await
.map_err(|error| session_sql_error("commit delete session", error))?;
diff --git a/crates/store-pg/tests/session_lifecycle_live.rs b/crates/store-pg/tests/session_lifecycle_live.rs
index 6908d5f53..aa48f896b 100644
--- a/crates/store-pg/tests/session_lifecycle_live.rs
+++ b/crates/store-pg/tests/session_lifecycle_live.rs
@@ -445,6 +445,141 @@ async fn cleanup_universe(store: &PgStore) {
.expect("clean up test universe");
}
+#[tokio::test(flavor = "current_thread")]
+#[ignore = "requires ./dev.sh infra or compatible Postgres env"]
+async fn permanent_delete_checks_lifecycle_and_removes_retained_descendants() {
+ use harness::{session::SessionPosition, storage::DeleteClosedSessions};
+ let store = live_store().await;
+ let root = SessionId::new("permanent-root");
+ let fork = SessionId::new("permanent-fork");
+ let clone_id = SessionId::new("independent-clone");
+ let create = CreateSession {
+ session_id: root.clone(),
+ display_name: None,
+ metadata: Default::default(),
+ origin: None,
+ delete_after_close_ms: None,
+ created_at_ms: 1,
+ };
+ store.create_session(create.clone()).await.unwrap();
+ store
+ .append(AppendSessionEvents {
+ session_id: root.clone(),
+ expected_head: None,
+ events: vec![lifecycle_event(10, CORE_AGENT_LIFECYCLE_OPENED_EVENT_KIND)],
+ })
+ .await
+ .unwrap();
+ let deletion = DeleteClosedSessions {
+ session_id: root.clone(),
+ cascade: true,
+ due_at_or_before_ms: None,
+ };
+ assert!(matches!(
+ store.delete_closed_sessions(deletion.clone()).await,
+ Err(SessionStoreError::SessionNotClosed { .. })
+ ));
+ store
+ .create_forked_session(CreateForkedSession {
+ session_id: fork.clone(),
+ source_session_id: root.clone(),
+ source_seq: EventSeq::new(1),
+ created_at_ms: 11,
+ })
+ .await
+ .unwrap();
+ store
+ .create_cloned_session(CreateClonedSession {
+ session_id: clone_id.clone(),
+ source_session_id: root.clone(),
+ created_at_ms: 12,
+ opening_events: vec![],
+ })
+ .await
+ .unwrap();
+ store
+ .append(AppendSessionEvents {
+ session_id: root.clone(),
+ expected_head: Some(SessionPosition {
+ seq: EventSeq::new(1),
+ }),
+ events: vec![lifecycle_event(20, CORE_AGENT_LIFECYCLE_CLOSED_EVENT_KIND)],
+ })
+ .await
+ .unwrap();
+ assert!(matches!(
+ store.delete_closed_sessions(deletion.clone()).await,
+ Err(SessionStoreError::SessionTreeNotClosed { .. })
+ ));
+ assert!(store.load_session(&root).await.unwrap().is_some());
+ store
+ .append(AppendSessionEvents {
+ session_id: fork.clone(),
+ expected_head: Some(SessionPosition {
+ seq: EventSeq::new(1),
+ }),
+ events: vec![lifecycle_event(21, CORE_AGENT_LIFECYCLE_CLOSED_EVENT_KIND)],
+ })
+ .await
+ .unwrap();
+ store
+ .delete_closed_sessions_with_options(
+ DeleteClosedSessions {
+ session_id: fork.clone(),
+ cascade: false,
+ due_at_or_before_ms: None,
+ },
+ true,
+ false,
+ )
+ .await
+ .unwrap();
+ // A retained fork still depends on the parent's events, even while hidden.
+ assert!(matches!(
+ store
+ .delete_closed_sessions(DeleteClosedSessions {
+ cascade: false,
+ ..deletion.clone()
+ })
+ .await,
+ Err(SessionStoreError::SessionHasChildren { .. })
+ ));
+ let deleted = store.delete_closed_sessions(deletion).await.unwrap();
+ assert_eq!(deleted.deleted_session_ids.len(), 2);
+ let retained: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM sessions WHERE universe_id=$1 AND session_id=ANY($2)",
+ )
+ .bind(store.config().universe_id)
+ .bind([root.as_str(), fork.as_str()])
+ .fetch_one(store.pool())
+ .await
+ .unwrap();
+ assert_eq!(retained, 0);
+ let events: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM session_events WHERE universe_id=$1 AND session_id=ANY($2)",
+ )
+ .bind(store.config().universe_id)
+ .bind([root.as_str(), fork.as_str()])
+ .fetch_one(store.pool())
+ .await
+ .unwrap();
+ assert_eq!(events, 0);
+ assert!(
+ store
+ .load_session(&clone_id)
+ .await
+ .unwrap()
+ .unwrap()
+ .source_session_id
+ .is_none()
+ );
+ store
+ .create_session(create)
+ .await
+ .expect("permanent deletion releases the session ID");
+ cleanup_universe(&store).await;
+}
+
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires ./dev.sh infra or compatible Postgres env"]
async fn soft_delete_hides_history_until_explicit_permanent_deletion() {
@@ -522,7 +657,7 @@ async fn soft_delete_hides_history_until_explicit_permanent_deletion() {
);
assert!(matches!(
store
- .delete_closed_sessions_with_visibility(deletion.clone(), true)
+ .delete_closed_sessions_with_options(deletion.clone(), true, true)
.await,
Err(SessionStoreError::SessionNotFound { .. })
));
@@ -535,7 +670,7 @@ async fn soft_delete_hides_history_until_explicit_permanent_deletion() {
.unwrap()
);
let deleted = store
- .delete_closed_sessions_with_visibility(deletion.clone(), true)
+ .delete_closed_sessions_with_options(deletion.clone(), true, true)
.await
.unwrap();
assert_eq!(deleted.deleted_session_ids.len(), 2);
diff --git a/crates/store-pg/tests/store_pg_live.rs b/crates/store-pg/tests/store_pg_live.rs
index 07fad9a15..763041077 100644
--- a/crates/store-pg/tests/store_pg_live.rs
+++ b/crates/store-pg/tests/store_pg_live.rs
@@ -1277,11 +1277,15 @@ async fn pg_live_sweep_frees_only_unreachable_blobs_after_grace() {
);
store
- .delete_closed_sessions(DeleteClosedSessions {
- session_id: doomed.clone(),
- cascade: true,
- due_at_or_before_ms: None,
- })
+ .delete_closed_sessions_with_options(
+ DeleteClosedSessions {
+ session_id: doomed.clone(),
+ cascade: true,
+ due_at_or_before_ms: None,
+ },
+ true,
+ false,
+ )
.await
.expect("delete doomed session");
assert_eq!(
@@ -1542,11 +1546,6 @@ async fn pg_live_sweep_follows_fork_trees_and_clone_roots() {
.await
.expect("delete fork tree");
assert_eq!(deleted.deleted_session_ids.len(), 2);
- assert!(candidate_refs(&store, cutoff_ms, &pinned).await.is_empty());
- store
- .purge_deleted_session(root.as_str())
- .await
- .expect("purge fork tree");
assert_eq!(
candidate_refs(&store, cutoff_ms, &pinned).await,
vec![fork_only.clone()]
@@ -1559,10 +1558,6 @@ async fn pg_live_sweep_follows_fork_trees_and_clone_roots() {
})
.await
.expect("delete clone");
- store
- .purge_deleted_session(clone.as_str())
- .await
- .expect("purge clone");
assert_eq!(
candidate_refs(&store, cutoff_ms, &pinned).await,
sorted([&config_blob, &fork_only])
diff --git a/crates/temporal-runtime/src/gateway/service/mod.rs b/crates/temporal-runtime/src/gateway/service/mod.rs
index b29d25fdd..156dfc60e 100644
--- a/crates/temporal-runtime/src/gateway/service/mod.rs
+++ b/crates/temporal-runtime/src/gateway/service/mod.rs
@@ -2680,6 +2680,7 @@ impl AgentApiService for GatewayAgentApi {
due_at_or_before_ms: None,
},
crate::session_deletion::SessionDeletionCause::Manual,
+ params.soft_delete,
params.shared_only,
)
.await
diff --git a/crates/temporal-runtime/src/session_deletion.rs b/crates/temporal-runtime/src/session_deletion.rs
index 54327672d..10285be89 100644
--- a/crates/temporal-runtime/src/session_deletion.rs
+++ b/crates/temporal-runtime/src/session_deletion.rs
@@ -21,12 +21,13 @@ pub(crate) async fn delete_session_subtree(
store: &PgStore,
request: DeleteClosedSessions,
cause: SessionDeletionCause,
+ soft_delete: bool,
shared_only: bool,
) -> Result {
let requested_session_id = request.session_id.clone();
let cascade = request.cascade;
let deleted = store
- .delete_closed_sessions_with_visibility(request, shared_only)
+ .delete_closed_sessions_with_options(request, soft_delete, shared_only)
.await?;
tracing::info!(
target: "temporal_runtime",
@@ -34,6 +35,7 @@ pub(crate) async fn delete_session_subtree(
retention_root_session_id = %deleted.target.retention_root_session_id,
deleted_session_count = deleted.deleted_session_ids.len(),
cascade,
+ soft_delete,
cause = cause.as_str(),
"session deletion complete"
);
diff --git a/crates/temporal-runtime/src/worker/reaper.rs b/crates/temporal-runtime/src/worker/reaper.rs
index 3022c2108..839818179 100644
--- a/crates/temporal-runtime/src/worker/reaper.rs
+++ b/crates/temporal-runtime/src/worker/reaper.rs
@@ -6,7 +6,7 @@
//! cancellation. The promise reaper is the backstop for the cases that no
//! single workflow can repair by itself: missed signals, terminated
//! workflows, or promise/source state that is only visible by scanning
-//! session logs. The retention reaper deletes closed session trees whose
+//! session logs. The retention reaper soft-deletes closed session trees whose
//! deadline passed, and the blob sweeper frees the blobs nothing references
//! any more.
@@ -513,6 +513,7 @@ impl SessionRetentionReaper {
due_at_or_before_ms: Some(now_ms),
},
SessionDeletionCause::Retention,
+ true,
false,
)
.await;
diff --git a/crates/temporal-runtime/tests/profiles_live.rs b/crates/temporal-runtime/tests/profiles_live.rs
index 8d1cb9756..88bdfdd1c 100644
--- a/crates/temporal-runtime/tests/profiles_live.rs
+++ b/crates/temporal-runtime/tests/profiles_live.rs
@@ -198,6 +198,7 @@ async fn run_profile_environment_selection_live_client(
);
api.delete_session(api::SessionDeleteParams {
shared_only: false,
+ soft_delete: false,
session_id: session_id.to_string(),
cascade: false,
})
diff --git a/crates/temporal-runtime/tests/sessions_live.rs b/crates/temporal-runtime/tests/sessions_live.rs
index 541773316..7f4094bec 100644
--- a/crates/temporal-runtime/tests/sessions_live.rs
+++ b/crates/temporal-runtime/tests/sessions_live.rs
@@ -612,6 +612,7 @@ async fn run_checkpoint_and_bounded_reads_live_client(
wait_for_session_status(&api, &session_id, SessionStatus::Closed).await?;
api.delete_session(SessionDeleteParams {
shared_only: false,
+ soft_delete: false,
session_id: session_id.as_str().to_owned(),
cascade: false,
})
@@ -918,11 +919,33 @@ async fn run_lifecycle_delete_live_client(
client: Client,
task_queue: String,
session_id: SessionId,
+) -> anyhow::Result<()> {
+ run_lifecycle_delete_mode(
+ client.clone(),
+ task_queue.clone(),
+ session_id.clone(),
+ false,
+ )
+ .await?;
+ run_lifecycle_delete_mode(
+ client,
+ task_queue,
+ SessionId::new(format!("{session_id}-soft")),
+ true,
+ )
+ .await
+}
+
+async fn run_lifecycle_delete_mode(
+ client: Client,
+ task_queue: String,
+ session_id: SessionId,
+ soft_delete: bool,
) -> anyhow::Result<()> {
let store = pg_store_from_env().await?;
let model = support::live::openai_live_model();
support::live::seed_agent_default(&store, &model).await?;
- let api = GatewayAgentApi::builder(client, store)
+ let api = GatewayAgentApi::builder(client, store.clone())
.with_task_queue(task_queue)
.build();
@@ -949,6 +972,7 @@ async fn run_lifecycle_delete_live_client(
let delete_open = api
.delete_session(SessionDeleteParams {
shared_only: false,
+ soft_delete,
session_id: session_id.as_str().to_owned(),
cascade: false,
})
@@ -977,6 +1001,7 @@ async fn run_lifecycle_delete_live_client(
let deleted = api
.delete_session(SessionDeleteParams {
shared_only: false,
+ soft_delete,
session_id: session_id.as_str().to_owned(),
cascade: false,
})
@@ -1002,6 +1027,19 @@ async fn run_lifecycle_delete_live_client(
.await
.expect_err("deleted session must not be readable");
assert_eq!(read_deleted.kind, AgentApiErrorKind::NotFound);
+ let retained: i64 =
+ sqlx::query_scalar("SELECT count(*) FROM sessions WHERE universe_id=$1 AND session_id=$2")
+ .bind(store.config().universe_id)
+ .bind(session_id.as_str())
+ .fetch_one(store.pool())
+ .await?;
+ assert_eq!(retained, i64::from(soft_delete));
+ if soft_delete {
+ assert_eq!(
+ store.purge_deleted_session(session_id.as_str()).await?,
+ vec![session_id.as_str().to_owned()]
+ );
+ }
Ok(())
}
diff --git a/docs/documentation/access-and-security/api-keys-and-service-access.md b/docs/documentation/access-and-security/api-keys-and-service-access.md
index 1d9dba430..9cdda70cd 100644
--- a/docs/documentation/access-and-security/api-keys-and-service-access.md
+++ b/docs/documentation/access-and-security/api-keys-and-service-access.md
@@ -44,10 +44,13 @@ session mutations as well as reads and blob reads. `blobs/put` is separate so
a connector can upload attachments without reading sessions. Exact groups are
listed in the [API reference](../../../crates/api/contract/api-reference.md).
-Soft deletion and retention changes require the separate `session/delete`
-group; existing `session` keys do not gain those capabilities. Listing deleted
-sessions and permanently deleting them require a deployment key with
-`deployment/sessions`. Provision these groups on service keys that need them.
+Session deletion and retention changes require the separate `session/delete`
+group; existing `session` keys do not gain those capabilities. Direct
+`session/delete` calls permanently delete by default; `softDelete: true` retains
+hidden history. Listing and purging already soft-deleted sessions require a
+deployment key with `deployment/sessions`. Provision these groups on service
+keys that need them. Platform always forces soft deletion for member requests
+and reserves its audited purge route for Platform admins.
An actor assertion uses `x-lightspeed-actor`. Only keys created with
`assertActor` may send it. Core records the opaque actor ID for attribution;
diff --git a/docs/documentation/deployment/operations.md b/docs/documentation/deployment/operations.md
index e655c965c..7f3c5430d 100644
--- a/docs/documentation/deployment/operations.md
+++ b/docs/documentation/deployment/operations.md
@@ -154,7 +154,7 @@ Session closure, soft deletion, permanent deletion, blob collection, Temporal
history retention, and machine cleanup are separate operations. Closing a session
keeps its history. If the root session has a `deleteAfterCloseMs` policy,
it owns the later soft-deletion deadline; otherwise there is no automatic
-deletion deadline. Setting deletion retention requires an Admin.
+deletion deadline. In Platform, setting deletion retention requires an Admin.
Forked and delegated descendants share that retained root. Automatic soft
deletion waits until the deadline and until the entire retained tree is closed. An
@@ -163,11 +163,12 @@ retention reaper checks every five minutes and reports due roots, deletions,
open-tree skips, conflicts, and errors.
Soft deletion hides sessions but retains events, checkpoints and blob roots.
-There is no automatic purge: a Platform admin must
-[permanently delete the retained history](../using-lightspeed/sessions-and-runs.md#close-and-retain-a-session)
-before its blob roots are released. Retention alone therefore does not reclaim
-that storage. It also does not remove workspaces, environments, backups or
-Temporal history.
+There is no automatic purge: use `deployment/sessions/purge` or
+[Platform administration](../using-lightspeed/sessions-and-runs.md#close-and-retain-a-session)
+to permanently remove retained history and release its blob roots. Retention
+alone therefore does not reclaim that storage. Direct runtime `session/delete` calls permanently delete closed
+sessions by default; `softDelete: true` retains them instead. Neither mode
+removes workspaces, environments, backups or Temporal history.
Blob collection runs separately. One elected `sessions` process examines the
CAS catalog hourly for old blobs without durable holders. The default grace
diff --git a/docs/documentation/using-lightspeed/sessions-and-runs.md b/docs/documentation/using-lightspeed/sessions-and-runs.md
index e80b7e418..7818afbfb 100644
--- a/docs/documentation/using-lightspeed/sessions-and-runs.md
+++ b/docs/documentation/using-lightspeed/sessions-and-runs.md
@@ -308,8 +308,8 @@ Leaving a browser tab or quitting the CLI has no effect on session lifecycle.
accept more work or be reopened. Its retained history is still inspectable.
**Force close session** also cancels outstanding work, including queued runs.
-Deleting a closed session **soft-deletes** it: it disappears from ordinary
-session views for everyone, including Admins, while its history remains stored.
+Deleting a closed session in Platform **soft-deletes** it: it disappears from
+ordinary session views for everyone, including Admins, while its history remains stored.
**Also delete forks and delegated children** includes its retention descendants,
which must also be closed; configuration-only clones are separate. Without
that option, the session must have no retention descendants. Closing and
@@ -320,8 +320,8 @@ closed session tree; a blank value keeps it until manual deletion. The root
owns this policy for its retention tree, and deletion waits until all descendants
are closed. This policy never permanently removes stored history.
-Only Platform admins can permanently delete retained session history. In the
-delete dialog, select **Also permanently delete retained history** to soft-delete
+Within Platform, only Platform admins can permanently delete retained session
+history. In the delete dialog, select **Also permanently delete retained history** to soft-delete
and then purge. For already deleted sessions, open **Platform admin → Universes →
Deleted sessions**, select sessions and choose **Purge selected…**, or use
**Purge all…**, then confirm removal of their deleted history trees. There is
@@ -329,6 +329,13 @@ no automatic purge or restore action.
Permanent deletion through Platform is recorded in the
[audit log](../access-and-security/overview.md#what-is-recorded-today).
+Direct runtime clients use `session/delete`, which permanently deletes by
+default; pass `softDelete: true` to retain hidden history. Similarly,
+`lightspeed session delete` deletes permanently unless `--soft-delete` is set.
+Both modes require closed sessions and use `cascade` for retention descendants.
+Platform always requests soft deletion before its separate admin purge step;
+automatic retention also continues to soft-delete only.
+
Bot and channel conversations have a lifecycle controller. Their session
inspector identifies what manages them; use that controller's reset or close
actions. If you enable direct input in a managed-session inspector, you bypass
diff --git a/docs/roadmap/p190-session-deletion-and-closing.md b/docs/roadmap/p190-session-deletion-and-closing.md
index 21c27cb0f..ee5b95174 100644
--- a/docs/roadmap/p190-session-deletion-and-closing.md
+++ b/docs/roadmap/p190-session-deletion-and-closing.md
@@ -6,8 +6,9 @@ Status: implemented and validated.
Closing ends execution and retains readable history. Soft deletion makes a closed
session disappear from every ordinary universe interface, including admin views.
-Only Platform administration can inspect deleted-session metadata and permanently
-remove the retained session records.
+Within Platform, only Platform administration can inspect deleted-session
+metadata and permanently remove the retained session records. Direct runtime
+clients can permanently delete closed sessions without first soft-deleting them.
| Role | Close | Soft delete | Permanently delete |
| --- | --- | --- | --- |
@@ -25,11 +26,17 @@ controller rules still apply to managed sessions.
## Storage and deletion
-`session/delete` sets `sessions.deleted_at_ms`. There are no tombstone or runtime
-audit tables. Every selected session must already be closed. Existing leaf and
-explicit cascade behavior remains: history forks and delegated descendants are
-included; configuration-only clones remain independent. An atomic shared-only
-guard prevents operator cascades from deleting private forks.
+`session/delete` permanently deletes by default. With `softDelete: true`, it sets
+`sessions.deleted_at_ms` instead. Platform forces that option server-side for
+every member, including admins; retention also explicitly chooses soft deletion.
+The CLI defaults to permanent deletion and exposes `--soft-delete`.
+There are no tombstone or runtime audit tables. Every selected session must
+already be closed. Existing leaf and explicit cascade behavior remains:
+history forks and delegated descendants are
+included; permanent cascades also include already soft-deleted descendants so
+retained forks cannot lose their source history. Configuration-only clones
+remain independent. An atomic shared-only guard prevents operator cascades
+from deleting private forks.
Normal reads, lists, access lookups, mutations, event reads, and source-based
creation exclude deleted sessions. Universe admins get no trash view or
@@ -38,8 +45,9 @@ its scheduling remains admin-only. Retained events, checkpoints and CAS roots
remain intact until explicit permanent deletion. Soft-deleted rows prevent reuse
of their session IDs while retained.
-Permanent deletion is a deployment-scoped operation, exposed only to platform
-admins through the Universes administration page or an unchecked-by-default
+Purging already soft-deleted sessions is a deployment-scoped operation, exposed
+in Platform only to platform admins through the Universes administration page
+or an unchecked-by-default
“Also permanently delete retained history” option in the ordinary session delete
dialog. Both paths use the same audited Platform purge endpoint. The inline path
soft-deletes first, then purges; a failed purge leaves a retry available without
@@ -69,9 +77,9 @@ Platform enforces the role and audience rules on the server, including direct
HTTP requests, and mirrors them in individual and bulk UI controls. The runtime
continues to enforce service-key scopes and method groups, not human memberships.
-`session/delete` and `session/retention/put` require the explicit `session/delete`
-key group. Existing ordinary `session` keys do not silently acquire deletion
-access. Administrators deploying this change must provision that capability on
+`session/delete` (either mode) and `session/retention/put` require the explicit
+`session/delete` key group. Existing ordinary `session` keys do not silently
+acquire deletion access. Administrators deploying this change must provision that capability on
service keys that need it. Deleted-session administration requires a deployment
key with `deployment/sessions`; Platform independently requires platform admin.
@@ -107,8 +115,14 @@ as successful actions; empty purge retries do not duplicate successful purge log
- [x] Align individual, bulk and demo lifecycle controls.
- [x] Complete regression coverage, regenerated contracts and component checks.
- [x] Align existing user, access and operations guides with deletion and audit behavior.
+- [x] Restore permanent deletion by default for direct runtime clients, with explicit soft deletion in Platform and retention.
Validation passed: workspace Clippy with warnings denied; API, PostgreSQL-store
and runtime unit tests; local PostgreSQL lifecycle and CAS-retention tests; the
serialized live bot history/recreation test; full backend, web, SDK and Configurator
tests; TypeScript checks; production web/demo builds; and release metadata checks.
+
+Direct-deletion follow-up validated with API schema/default tests, PostgreSQL
+lifecycle and blob-retention tests, both deletion modes through the live runtime
+API and CLI, Platform permission/audit regressions, SDK and Configurator tests,
+TypeScript checks, documentation checks and workspace Clippy.
diff --git a/platform/backend/src/routes/gateway.ts b/platform/backend/src/routes/gateway.ts
index c4183ce75..c0916109a 100644
--- a/platform/backend/src/routes/gateway.ts
+++ b/platform/backend/src/routes/gateway.ts
@@ -547,6 +547,7 @@ export function gatewayRoutes(ctx: AppContext) {
const client = engineClientFor(ctx, access);
const response = await client.call("session/delete", {
sessionId: c.req.param("sessionId"),
+ softDelete: true,
cascade: c.req.query("cascade") === "true",
});
return c.json(response.result.session);
diff --git a/platform/backend/src/routes/session-lifecycle.test.ts b/platform/backend/src/routes/session-lifecycle.test.ts
index b6cbfe3ce..e50c4b74d 100644
--- a/platform/backend/src/routes/session-lifecycle.test.ts
+++ b/platform/backend/src/routes/session-lifecycle.test.ts
@@ -15,9 +15,11 @@ afterEach(() => vi.unstubAllGlobals());
function fixture({ failMutation = false, purgedIds = ["s1", "child"] }: { failMutation?: boolean; purgedIds?: string[] } = {}) {
const audits = vi.fn(async () => undefined);
const rpcCalls: string[] = [];
+ const rpcParams: Record[] = [];
vi.stubGlobal("fetch", vi.fn(async (_url: unknown, init: RequestInit) => {
const rpc = JSON.parse(String(init.body));
rpcCalls.push(rpc.method);
+ rpcParams.push(rpc.params);
if (failMutation && rpc.method !== "session/read") {
return Response.json({ id: rpc.id, error: { code: -32009, message: "failed", data: { kind: "conflict" } } });
}
@@ -38,7 +40,7 @@ function fixture({ failMutation = false, purgedIds = ["s1", "child"] }: { failMu
const call = (path: string, method = "POST", body: unknown = {}) => app.request(path, { method,
headers: { "content-type": "application/json" }, ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
});
- return { call, audits, rpcCalls };
+ return { call, audits, rpcCalls, rpcParams };
}
it.each([false, true])("closes with force=%s without writing an audit record", async (force) => {
@@ -47,11 +49,12 @@ it.each([false, true])("closes with force=%s without writing an audit record", a
expect(f.rpcCalls).toContain("session/close");
expect(f.audits).not.toHaveBeenCalled();
});
-it("soft deletes shared sessions without writing an audit record", async () => {
- auth.role = "operator";
+it.each(["operator", "admin"])("%s always soft deletes even if the URL requests permanent deletion", async (role) => {
+ auth.role = role;
const f = fixture();
- expect((await f.call("/universes/universe/sessions/s1?cascade=true", "DELETE")).status).toBe(200);
+ expect((await f.call("/universes/universe/sessions/s1?cascade=true&softDelete=false", "DELETE")).status).toBe(200);
expect(f.rpcCalls).toContain("session/delete");
+ expect(f.rpcParams[f.rpcCalls.indexOf("session/delete")]).toMatchObject({ sessionId: "s1", cascade: true, softDelete: true });
expect(f.audits).not.toHaveBeenCalled();
});
it("does not record successful purge when the runtime rejects it", async () => {
diff --git a/platform/backend/src/runtime-client.test.ts b/platform/backend/src/runtime-client.test.ts
index 5091788a8..c5dc7891b 100644
--- a/platform/backend/src/runtime-client.test.ts
+++ b/platform/backend/src/runtime-client.test.ts
@@ -223,8 +223,9 @@ describe("session lifecycle permissions", () => {
}
for (const shared of [true, false]) it(`${role} soft deletes shared=${shared} only when permitted`, async () => {
const calls = core({ s: { visibility: shared ? "universe" : "restricted", createdBy: { kind: "actor", id: "alice" } } });
- const error = await refusal(as(role).call("session/delete", { sessionId: "s", sharedOnly: false }));
+ const error = await refusal(as(role).call("session/delete", { sessionId: "s", sharedOnly: false, softDelete: false }));
expect(error === null).toBe(role === "admin" || (role === "operator" && shared));
+ if (error === null) expect(calls.at(-1)?.params.softDelete).toBe(true);
if (role === "operator" && shared) expect(calls.at(-1)?.params.sharedOnly).toBe(true);
});
it(`${role} deletes and configures retention only as admin`, async () => {
diff --git a/platform/backend/src/runtime-client.ts b/platform/backend/src/runtime-client.ts
index 5fd1a52ad..a2c179998 100644
--- a/platform/backend/src/runtime-client.ts
+++ b/platform/backend/src/runtime-client.ts
@@ -95,8 +95,9 @@ class MemberClient extends LightspeedClient {
}
}
const admin = this.member.role === "admin";
- if (!admin && method === "session/delete") {
- params = { ...params, sharedOnly: true } as MethodParams;
+ if (method === "session/delete") {
+ // Permanent deletion must use the separately authorized, audited purge route.
+ params = { ...params, softDelete: true, ...(!admin ? { sharedOnly: true } : {}) } as MethodParams;
}
if (!admin && CREATION_METHODS.has(method)) {
const start = params as { deleteAfterCloseMs?: number | null };
diff --git a/platform/configurator-mcp/src/generated/tools.ts b/platform/configurator-mcp/src/generated/tools.ts
index 195b90af7..12ad6bd07 100644
--- a/platform/configurator-mcp/src/generated/tools.ts
+++ b/platform/configurator-mcp/src/generated/tools.ts
@@ -70,7 +70,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
]
},
"deleteAfterCloseMs": {
- "description": "Root-owned automatic deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
+ "description": "Root-owned automatic soft deletion measured from close. Absent inherits a\nprofile default, explicit null keeps the tree, and a duration overrides\nthe profile.",
"format": "uint64",
"maximum": 3153600000000,
"minimum": 1,
@@ -2176,7 +2176,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
"method": "session/retention/put",
"group": "session/delete",
"summary": "Replace session retention",
- "description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.",
+ "description": "Sets the positive close-relative automatic soft-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. Retention does not permanently delete stored history.",
"paramsType": "SessionRetentionPutParams",
"resultType": "AgentApiOutcome",
"inputSchema": {
@@ -2186,7 +2186,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
},
"properties": {
"deleteAfterCloseMs": {
- "description": "Positive duration enables automatic tree deletion; null disables it.",
+ "description": "Positive duration enables automatic tree soft deletion; null disables it.",
"format": "uint64",
"minimum": 1,
"type": [
@@ -2236,7 +2236,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
"method": "session/delete",
"group": "session/delete",
"summary": "Delete closed sessions",
- "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Records and history are retained until a deployment administrator permanently deletes them. Config-only clones are never included.",
+ "description": "Permanently deletes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Set softDelete to true to hide sessions while retaining records and history until a deployment administrator purges them. Prior soft deletion is not required for permanent deletion. Config-only clones are never included.",
"paramsType": "SessionDeleteParams",
"resultType": "AgentApiOutcome",
"inputSchema": {
@@ -2252,6 +2252,10 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [
"sharedOnly": {
"description": "Atomically require every selected session's audience to be shared.\nDelegating services use this guard for operators deleting shared work.",
"type": "boolean"
+ },
+ "softDelete": {
+ "description": "Hide sessions while retaining records, events, checkpoints and blob roots.\nFalse (the default) permanently deletes them without prior soft deletion.",
+ "type": "boolean"
}
},
"required": [
diff --git a/platform/web/src/components/markdown-view-toggle.tsx b/platform/web/src/components/markdown-view-toggle.tsx
index 033c99ea0..897c19cbe 100644
--- a/platform/web/src/components/markdown-view-toggle.tsx
+++ b/platform/web/src/components/markdown-view-toggle.tsx
@@ -1,19 +1,16 @@
-import { Button } from "@/components/ui/button";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export function MarkdownViewToggle({ preview, onPreviewChange }: {
preview: boolean;
onPreviewChange: (preview: boolean) => void;
}) {
return (
-