diff --git a/docs/howto/run-ooda-daemon.md b/docs/howto/run-ooda-daemon.md index 1859364b9..90611eef5 100644 --- a/docs/howto/run-ooda-daemon.md +++ b/docs/howto/run-ooda-daemon.md @@ -1,11 +1,12 @@ --- title: How to run the OODA daemon description: Procedure for installing Simard through the canonical installer rail, running the OODA and Signal services as user-level systemd units, and verifying the autonomous loop. -last_updated: 2026-07-09 +last_updated: 2026-07-30 review_schedule: as-needed owner: simard doc_type: howto related: + - ../reference/actor-session-startup-purge.md - ../reference/simard-installer.md - ../reference/simard-cli.md - ../architecture/overview.md @@ -91,6 +92,22 @@ journalctl --user -u simard-signal.service -n 100 --no-pager The OODA service should emit cycle summaries. The Signal service is expected to stay running when the `[signal]` table is configured; if Signal is not configured, it exits or reports the missing configuration according to the Signal channel contract. +!!! note "Actor-session startup cleanup (#5005)" + Every OODA daemon start clears prior-process actor sessions before + goal-cycle work. + +The cleanup makes restarts, state-root migrations, and changes to +`SIMARD_OBSERVE_ONLY` self-healing even when copied leases have future expiry +times. It affects only the transient `actor_sessions` table in +`$SIMARD_HOME/typed-ooda/outcomes.sqlite3`; durable outcomes, requests, effects, +and claims must remain intact. + +The startup path fails if the ledger cannot be opened or purged instead of +running cycles against stale lease state. Do not work around startup failures +with a manual SQLite `DELETE`; correct the reported path, permission, lock, or +storage error and restart the service. See the +[actor-session startup purge reference](../reference/actor-session-startup-purge.md). + If your provider depends on environment variables, import them into the user systemd manager before restarting services: @@ -210,6 +227,7 @@ See [How to set up the Signal channel](./set-up-the-signal-channel.md). ## See also +- [Actor-session startup purge](../reference/actor-session-startup-purge.md) - [Simard installer reference](../reference/simard-installer.md) - [Simard CLI reference](../reference/simard-cli.md) - [Daemon mode](../daemon-mode.md) diff --git a/docs/index.md b/docs/index.md index 34dd4138b..fd6c8d892 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- title: Simard documentation description: Start here for the shipped `simard` operator CLI, typed-capability OODA execution, verified-backup installation, the repo-grounded engineer loop, runtime contracts, and benchmark flow. -last_updated: 2026-07-14 +last_updated: 2026-07-30 review_schedule: as-needed owner: simard --- @@ -29,6 +29,7 @@ Terminal sessions and repo-grounded engineer runs now bridge through one explici - [Reference: `simard ooda record-decision` (typed decision tool)](./reference/ooda-record-decision-cli.md) — the zero-privilege CLI the per-goal-cycle reasoner calls to record exactly one typed, closed-enum decision, replacing the forbidden "recipe emits JSON → Rust scrapes prose → Rust acts" pattern on the core decision path (#4720). Covers the `PerGoalDecisionRecord` file format, the fail-CLOSED `read_verified` matrix (absent/malformed/wrong-schema/out-of-enum/goal-or-cycle-mismatch ⇒ safe no-op, #1711), configuration, and security. - [Reference: `simard ooda record-orient` / `record-decide` (typed OODA orient+decide tools)](./reference/ooda-record-orient-decide-cli.md) — the two zero-privilege CLIs the orient and decide reasoners call to record one typed judgment each, extending the `record-decision` pattern to the core OODA orient (urgency demotion) and decide (action routing) phases as Group A of epic #4719. Covers the `OrientDecisionRecord` / `DecideDecisionRecord` file formats, the shared writer/reader chokepoints, the fail-CLOSED R1–R8 read matrix (absent/malformed/wrong-schema/out-of-enum/out-of-range/escalation/goal-or-cycle-mismatch ⇒ safe no-op, #1711), configuration, and security. - [Typed OODA goal-session deterministic rails](./reference/typed-ooda-goal-session-rails.md) — the two thin rail fixes that unblocked live OODA goals (#4076): propagating `AMPLIHACK_AGENT_BINARY` to the goal-session `recipe-runner-rs` subprocess (no silent `claude` fallback) and normalizing bare goal repo names to `rysweet/` at spawn admission, plus the additive Act-loop failure-detail log. +- [Actor-session startup purge](./reference/actor-session-startup-purge.md) — the #5005 daemon-startup contract for clearing prior-process actor leases from the canonical typed-OODA ledger before goal-cycle work while preserving durable records and fail-closed live scope enforcement. - [Tutorial: Complete a typed OODA cycle](./tutorials/complete-a-typed-ooda-cycle.md) - Deterministic action, no-action, replay, and conflict examples. - [Tutorial: Run your first local session](./tutorials/run-your-first-local-session.md) - Exercise the local runtime through the primary CLI. - [Simard installer reference](./reference/simard-installer.md) - Shipped deployment contract for the binary, the owned `~/.local/bin/simard` PATH entrypoint and stale-orphan reconciliation, prompt assets, user systemd units, the post-deploy version-parity gate, rollback artifacts, and dry-run controls. diff --git a/docs/reference/actor-session-scope-key-api.md b/docs/reference/actor-session-scope-key-api.md index e3253e568..3ffd9dfab 100644 --- a/docs/reference/actor-session-scope-key-api.md +++ b/docs/reference/actor-session-scope-key-api.md @@ -8,12 +8,13 @@ description: > re-leasing a stable session_id with a new cycle_id is legitimate, the unchanged actor_sessions upsert, and the regression test list. Fixes the false AuthorizationScopeViolation crash-loop on PERPETUAL/STANDING goals. -last_updated: 2026-07-19 +last_updated: 2026-07-30 review_schedule: as-needed owner: simard doc_type: reference status: implemented related: + - ./actor-session-startup-purge.md - ./stable-goal-session-identity-api.md - ./ooda-capability-api.md - ../concepts/stable-goal-session-identity.md @@ -52,8 +53,11 @@ changes on every tick. Actor-session leases in the typed-OODA ledger last 30 days ([`route.rs`](https://github.com/rysweet/Simard/blob/main/src/typed_ooda/route.rs), `ACTOR_SESSION_LEASE = Duration::from_secs(30 * 24 * 60 * 60)`). -`register_actor_session` only `DELETE`s **expired** rows, so a live lease never -clears between the ~7-minute cycle retries of a running goal. +Within a running daemon, `register_actor_session` only `DELETE`s **expired** +rows, so a live lease never clears between the ~7-minute cycle retries of a +running goal. The daemon clears all inherited actor-session leases once at +startup; see the +[actor-session startup purge](./actor-session-startup-purge.md). The `AuthorizationScopeViolation` guard previously compared the **entire** `ActorBinding`, including the per-cycle `cycle_id`. On re-lease: @@ -263,6 +267,10 @@ so token rotation is preserved. `validate_identifier` trust boundary are untouched. - **Request-replay idempotency** (`replay_request(request_id, "actor_session", fingerprint)`) is untouched; a retried `request_id` still returns the prior lease. +- **The daemon startup lifecycle** clears prior-process actor sessions before + goal-cycle work. It does not change this in-process scope guard or run from + `CapabilityHandler::open`; see the + [actor-session startup purge](./actor-session-startup-purge.md). ## Security invariants diff --git a/docs/reference/actor-session-startup-purge.md b/docs/reference/actor-session-startup-purge.md new file mode 100644 index 000000000..bc19f1ff1 --- /dev/null +++ b/docs/reference/actor-session-startup-purge.md @@ -0,0 +1,253 @@ +--- +title: "Reference: Actor-session startup purge" +description: Contract for clearing transient typed-OODA actor-session leases at daemon startup while preserving durable ledger records and live scope enforcement. +last_updated: 2026-07-30 +review_schedule: as-needed +owner: simard +doc_type: reference +status: implemented +related: + - ./actor-session-scope-key-api.md + - ./stable-goal-session-identity-api.md + - ./ooda-capability-api.md + - ../howto/run-ooda-daemon.md + - ../daemon-mode.md +--- + +# Reference: Actor-session startup purge + +The OODA daemon deletes persisted `actor_sessions` rows once during startup. +Actor-session leases represent actors that are active in the current daemon +process. No actor session remains in flight after that process exits, so carrying +these leases into a new process is invalid even when their `expires_at` value is +still in the future. + +The purge lets a stable per-goal `session_id` bind to the identity and +authorization scope of the new daemon process after a restart, host migration, +state-root migration, or observe-only posture change. It does not relax the +runtime scope guard: changing the immutable scope of a session that is already +registered in the current process still returns +`AuthorizationScopeViolation`. + +## Startup contract + +`run_ooda_daemon()` must perform actor-session cleanup in this order: + +1. Resolve the daemon state root. +2. Create the state-root directory. +3. Resolve the ledger with `typed_ooda::ledger_path(state_root)`. +4. Ensure the canonical ledger parent directory exists. +5. Open the typed-OODA ledger. +6. Call `CapabilityHandler::purge_actor_sessions()`. +7. Continue with the remaining daemon initialization and goal-cycle work. + +The canonical ledger path is: + +```text +/typed-ooda/outcomes.sqlite3 +``` + +The purge must run exactly once per `run_ooda_daemon()` invocation. It must not +run: + +- in `CapabilityHandler::open()`; +- during schema initialization or migration; +- when an operator opens the ledger through another command; +- before or after each goal cycle; +- during actor-session registration. + +Opening and purging the ledger are part of daemon startup. If either +operation fails, `run_ooda_daemon()` must return the error and must not begin +goal-cycle work. The daemon must never continue with stale actor-session state +after a failed purge. + +## Internal ledger operation + +The purge is crate-visible for daemon startup use. It is not part of the +public `CapabilityHandler` API for downstream consumers. + +```rust +impl CapabilityHandler { + /// Delete every transient actor-session lease. + /// + /// This operation is intended for authoritative daemon startup, when no + /// actor session can still be in flight. It is idempotent and returns + /// persistence failures through `CapabilityResult`. + pub(crate) fn purge_actor_sessions(&self) -> CapabilityResult<()>; +} +``` + +The operation must execute one hard-coded SQL statement: + +```sql +DELETE FROM actor_sessions +``` + +Deleting zero rows must succeed. SQLite open, lock, and execution errors must use +the existing typed-OODA persistence error conversion and be returned to the +caller. The method must not accept SQL, a table name, a state root, or a filter +from the caller. + +The daemon owns the lifecycle decision. `CapabilityHandler` exposes the +fallible deletion primitive but does not call it automatically. + +## Data lifecycle + +`actor_sessions` is transient authentication state. Its rows contain the +current token hash, expiry, cycle metadata, actor identity, repository binding, +grants, engineer permissions, working-directory binding, and observe-only +posture for an in-flight actor. + +All other typed-OODA ledger data must remain durable. The startup purge must not +delete or rewrite: + +- `terminal_outcomes`; +- `progress_records`; +- `mutation_requests`; +- `mutation_scope_counters`; +- `effect_jobs`; +- `engineer_claims`; +- approvals, process executions, or authorization records; +- schema metadata. + +Request replay records must be preserved. A fresh actor registration after +restart must therefore use a fresh `request_id`, as every new registration +already does. + +The existing expiry sweep must remain in place: + +```sql +DELETE FROM actor_sessions WHERE expires_at < ?1 +``` + +That sweep removes expired rows during schema creation or migration and +ordinary session registration, not on every ledger initialization. The +startup purge has a different purpose: it removes all leases inherited from a +process that no longer exists, including future-dated leases. + +## Scope enforcement after startup + +The immutable actor-session scope key continues to contain: + +| Field | Runtime behavior | +| --- | --- | +| Actor identity | A change on the same live `session_id` is rejected. | +| Repository | A change on the same live `session_id` is rejected. | +| Capability grants | A change on the same live `session_id` is rejected. | +| Engineer permissions | A change on the same live `session_id` is rejected. | +| Working directory | A change on the same live `session_id` is rejected. | +| Observe-only posture | A change on the same live `session_id` is rejected. | + +`cycle_id`, `goal_id`, and the rotating token hash must remain outside the +immutable scope key. See the +[actor-session scope-key API](./actor-session-scope-key-api.md) for the complete +registration contract. + +The target lifecycle boundary must produce these results: + +| Situation | Result | +| --- | --- | +| Same process, same stable session ID, same immutable scope | Registration may refresh the lease. | +| Same process, same stable session ID, changed immutable scope | `AuthorizationScopeViolation`. | +| New daemon process, stale persisted lease, changed immutable scope | Startup deletes the stale row; the first new registration succeeds. | +| Startup cannot open or purge the ledger | Daemon startup fails before goal-cycle work. | + +## Configuration + +The purge must have no enable/disable switch, retention period, row limit, or +separate database setting. + +| Input | Effect | +| --- | --- | +| Daemon state root | Selects the canonical ledger at `typed-ooda/outcomes.sqlite3`. | +| `SIMARD_STATE_ROOT` | Overrides the runtime state root when supported by the invoking command or service. | +| `SIMARD_HOME` | Supplies the installed service state root unless a deliberate state-root override is used. | +| `SIMARD_OBSERVE_ONLY` | Contributes to each new actor's immutable scope; after implementation, changing it across a restart is safe because startup clears prior-process leases. | +| Identity, repository, and working directory | Contribute to the new actor scope but do not change purge behavior. | + +`drain.conf` and identity posture can affect whether the new daemon runs +observe-only, but neither may change the cleanup rule. Startup must always +remove prior-process actor sessions. + +The lifecycle assumes one authoritative daemon process per state root. +Concurrent daemons sharing one state root are unsupported because one daemon's +startup could invalidate another daemon's live actor sessions. + +## Usage examples + +### Restart after changing observe-only posture + +Set the intended service environment, then restart the daemon: + +```bash +systemctl --user restart simard-ooda.service +journalctl --user -u simard-ooda.service -n 100 --no-pager +``` + +The new process clears actor sessions from its canonical typed-OODA ledger +before it starts a goal cycle. No manual SQLite deletion is required. + +For a bounded foreground check against an explicit state root: + +```bash +SIMARD_OBSERVE_ONLY=1 \ + simard ooda run --cycles=1 "$HOME/.simard" +``` + +The same startup cleanup must run before the bounded cycle. + +### Move a state root to another host + +After transferring the durable state root, start the daemon normally on the new +host: + +```bash +simard ooda run --cycles=1 "$HOME/.simard" +``` + +Future-dated actor leases copied with `typed-ooda/outcomes.sqlite3` must be +discarded at startup. Durable outcomes, effect records, claims, and request +history must remain available. + +Do not add an operator script that runs +`DELETE FROM actor_sessions`. The daemon startup lifecycle owns this cleanup and +surfaces failures through its normal startup error path. + +## Regression contract + +The persisted-SQLite regression test must use the real canonical ledger path +and prove all three required transitions: + +1. Register a stable session ID with a future-dated lease under one immutable + scope, then close that handler so the row is persisted. +2. Run the daemon startup purge and register the same session ID under a + different scope, including a changed `observe_only` value. Registration + succeeds because the prior-process row is gone. +3. Reuse the same running handler and attempt another scope change with a new + request ID. Registration fails with + `CapabilityErrorCode::AuthorizationScopeViolation`. + +Unique request IDs are required at each registration so request replay cannot +mask the lifecycle behavior. The test uses a future expiry so the ordinary +expiration sweep cannot produce a false positive. + +## Security properties + +- **Startup-only authority:** production code must invoke the purge only from + `run_ooda_daemon()` before any goal-cycle work. +- **Fail-visible startup:** ledger open and purge failures must abort startup. +- **Narrow deletion:** one fixed statement must delete only `actor_sessions`. +- **Live protection preserved:** the runtime scope-key comparison and + `AuthorizationScopeViolation` must remain unchanged. +- **No secret logging:** startup must not log actor identities, token hashes, + scope keys, or row contents. +- **Durable history preserved:** outcomes, requests, effects, claims, and other + authorization history must survive daemon restarts. + +## Related + +- [Actor-session scope-key API](./actor-session-scope-key-api.md) +- [Stable goal-session identity API](./stable-goal-session-identity-api.md) +- [OODA capability API](./ooda-capability-api.md) +- [How to run the OODA daemon](../howto/run-ooda-daemon.md) +- [Daemon mode](../daemon-mode.md) diff --git a/docs/reference/ooda-capability-api.md b/docs/reference/ooda-capability-api.md index e37021fc9..b70ade870 100644 --- a/docs/reference/ooda-capability-api.md +++ b/docs/reference/ooda-capability-api.md @@ -1,12 +1,13 @@ --- title: OODA capability API description: Implemented typed terminal, authorization, idempotency, actor-session, and effect-outbox contracts for parser-free goal-session execution. -last_updated: 2026-07-14 +last_updated: 2026-07-30 review_schedule: as-needed owner: simard doc_type: reference status: implemented related: + - ./actor-session-startup-purge.md - ../architecture/typed-ooda-loop.md - ../howto/spawn-engineers-from-ooda-daemon.md - ../operations/deploy-and-roll-back-typed-ooda.md @@ -33,6 +34,15 @@ The random token is passed through an owner-private context file. `ooda terminal` must present that token and the exact session, cycle, and goal. Expired or mismatched sessions return `Unauthenticated`. +Actor sessions are process-transient even though their SQLite rows are +persistent. The authoritative OODA daemon clears every inherited +`actor_sessions` row once at startup, before goal-cycle work. This lets the +stable per-goal session ID bind to the new process's scope after a restart or +posture change. A scope change against a session registered in the current +process still returns +`AuthorizationScopeViolation`. See the +[actor-session startup purge](./actor-session-startup-purge.md). + The terminal actor's tool schemas do not accept caller-supplied session, cycle, goal, repository authority, or grants. Those values come from the authenticated session and invocation. diff --git a/src/operator_commands_ooda/daemon/mod.rs b/src/operator_commands_ooda/daemon/mod.rs index f653c8e4a..3aa7676c9 100644 --- a/src/operator_commands_ooda/daemon/mod.rs +++ b/src/operator_commands_ooda/daemon/mod.rs @@ -102,6 +102,22 @@ fn clear_stale_draining_flag_at_boot_in(state_dir: &std::path::Path, log_root: & } } +fn purge_actor_sessions_on_startup( + state_root: &std::path::Path, +) -> Result<(), Box> { + let ledger_path = crate::typed_ooda::ledger_path(state_root); + let ledger_parent = ledger_path + .parent() + .ok_or_else(|| std::io::Error::other("typed-OODA ledger path has no parent directory"))?; + std::fs::create_dir_all(ledger_parent)?; + let handler = crate::typed_ooda::CapabilityHandler::open( + &ledger_path, + crate::typed_ooda::CapabilityPolicy::new("daemon-startup"), + )?; + handler.purge_actor_sessions()?; + Ok(()) +} + /// Resolve the identity-scoped cognition (#3125) for the daemon from the /// environment, **fail-closed**. /// @@ -281,6 +297,7 @@ pub fn run_ooda_daemon( let state_root = state_root_override.unwrap_or_else(memory_ipc::default_state_root); std::fs::create_dir_all(&state_root)?; + purge_actor_sessions_on_startup(&state_root)?; clear_stale_draining_flag_at_boot(&state_root); // Freshness gate at daemon startup (issue #439): belt-and-suspenders run of @@ -2388,6 +2405,74 @@ mod tests { assert!(crate::safe_update::draining_flag_path(state.path()).exists()); } + #[test] + fn startup_purge_removes_stale_actor_session_but_preserves_live_scope_guard() { + use crate::typed_ooda::{ + ActionKind, AuthenticatedToolContext, CapabilityErrorCode, CapabilityGrant, + CapabilityHandler, CapabilityPolicy, RepositoryRef, + }; + + let state = tempfile::tempdir().expect("state root"); + let ledger_path = crate::typed_ooda::ledger_path(state.path()); + std::fs::create_dir_all(ledger_path.parent().expect("ledger parent")) + .expect("create ledger directory"); + let actor = |cycle_id: &str, observe_only: bool| { + AuthenticatedToolContext::new( + "goal-session-actor", + "ooda-stable-goal-session", + [CapabilityGrant::RecordAction(ActionKind::SpawnEngineer)], + ) + .scoped_to_repository(RepositoryRef::new("rysweet", "Simard")) + .bound_to_cycle_goal(cycle_id, "goal-perpetual") + .with_engineer_permissions(["repo_read"]) + .with_observe_only(observe_only) + }; + let lease = Duration::from_secs(30 * 24 * 60 * 60); + + let prior_process = + CapabilityHandler::open(&ledger_path, CapabilityPolicy::new("policy-v1")) + .expect("open prior-process ledger"); + prior_process + .register_actor_session( + &actor("cycle-before-restart", false), + "request-before-restart", + "cycle-before-restart", + "goal-perpetual", + lease, + ) + .expect("persist future-dated prior-process lease"); + drop(prior_process); + + purge_actor_sessions_on_startup(state.path()).expect("startup purge must succeed"); + + let current_process = + CapabilityHandler::open(&ledger_path, CapabilityPolicy::new("policy-v1")) + .expect("reopen ledger after startup purge"); + current_process + .register_actor_session( + &actor("cycle-after-restart", true), + "request-after-restart", + "cycle-after-restart", + "goal-perpetual", + lease, + ) + .expect("startup purge must allow the stable session under its new scope"); + + let error = current_process + .register_actor_session( + &actor("cycle-live-scope-change", false), + "request-live-scope-change", + "cycle-live-scope-change", + "goal-perpetual", + lease, + ) + .expect_err("a live scope change must still be rejected"); + assert_eq!( + error.code(), + CapabilityErrorCode::AuthorizationScopeViolation + ); + } + // ── shutdown_daemon ───────────────────────────────────────────── #[test] diff --git a/src/typed_ooda/ledger.rs b/src/typed_ooda/ledger.rs index cf677f2f8..8ff0d2580 100644 --- a/src/typed_ooda/ledger.rs +++ b/src/typed_ooda/ledger.rs @@ -265,6 +265,17 @@ impl CapabilityHandler { }) } + /// Delete every transient actor-session lease. + /// + /// Intended for authoritative daemon startup, when no actor session can + /// still be in flight. Runtime scope enforcement remains unchanged. + pub(crate) fn purge_actor_sessions(&self) -> CapabilityResult<()> { + self.lock()? + .execute("DELETE FROM actor_sessions", []) + .map_err(persistence)?; + Ok(()) + } + /// Inject the authoritative engineer-liveness provider used by the /// `engineer_claims` reclaim gate. Without a provider the gate is /// fail-closed: an existing claim is treated as live and a duplicate spawn