# m4p1 — Session Schema and Lifecycle (✅ COMPLETE 2026-02-21) Phase spec and acceptance criteria: [ROADMAP · Milestone 4 · Phase 1](../ROADMAP.md). Milestone index: [README.md](README.md). Backfilled record — see the README's note on provenance. ## What shipped 1. **Session identity types** (`tidal/src/session/types.rs`). `SessionId` is a `u64` newtype (`Copy`, `Hash`, `Ord`, `Display` as `session:`) handed out monotonically by `start_session`; `from_raw` exists for deserialization. `AgentId` is a validated `String` newtype: 1–64 bytes, `[a-z0-9_-]` only — uppercase, spaces, empty, and 65-byte inputs are all rejected at construction. 2. **`SessionHandle` as the capability** (`tidal/src/session/state.rs:151`). Carries `id`, `user_id`, `agent_id`, `policy_name`, `started_at: Instant`, and a `closed: Arc` shared with the live `SessionState`. `close_session` takes the handle **by value**, so use-after-close is a compile error in the common case; the shared `closed` flag is the runtime defence-in-depth for handles cloned into another thread. 3. **Policy declaration in schema** (`tidal/src/schema/validation/policies.rs`). `AgentPolicy` with `allowed_signals`, `denied_signals`, `max_session_duration`, `max_signals_per_session` (`0` = unlimited), registered through `SchemaBuilder::session_policy(name, policy)` and read back via `Schema::session_policy(name)`. Validated at schema build time. 4. **Lifecycle API** (`tidal/src/db/sessions.rs`). `start_session(user_id, agent_id, policy_name, metadata) -> Result`, `close_session(handle) -> Result`, and `active_sessions() -> Vec<…>`. An undeclared policy name is refused at `start_session`, not at first write. 5. **WAL durability for the lifecycle**. `WalCommand::SessionStart` / `SessionClose` (`tidal/src/wal/writer.rs:101,124`) journal session boundaries on the same stream as signals; replay restores start-without-close as an active session and start-with-close as an archive. 6. **Archive keyspace.** `Tag::Session = 0x07` (`tidal/src/storage/keys.rs:29`) holds session snapshots and audit logs, so a closed session is readable after process restart, not just after close. ## Evidence | Criterion | Proof | |-----------|-------| | Schema accepts and returns a declared policy | `m4_uat.rs::step1_schema_with_session_policy_builds` | | Start → active_sessions → close → not active | `m4_uat.rs::step2_session_start_and_close` | | Multiple concurrent sessions tracked independently | `m4_uat.rs::step12_active_sessions_tracking` | | Undeclared policy name rejected | `m4_uat.rs::step10_invalid_policy_name_rejected` | | `AgentId` format enforcement | `m4_uat.rs::step11_agent_id_validation` | | Active session restored after crash | `session_durability.rs::active_session_state_restored_after_crash` | | Session metadata survives crash | `session_durability.rs::metadata_survives_crash` | | Closed session not resurrected as active | `review_pass2_zone_a_sessions.rs::closed_session_is_not_restored_as_active` | ## Divergence from the plan - **Error naming.** The ROADMAP says `LumenError::SessionExpired`; the shipped enum is `TidalError::SessionExpired` (`tidal/src/schema/error.rs:130`). - **Session-ID reuse across restart.** The doc comment on `SessionId` still says uniqueness is "not guaranteed across restarts". That is now stale: the review pass-2 remediation (`9728194`) added `review_pass2_zone_a_sessions.rs::reopen_does_not_reissue_archived_session_id`, which proves a reopened database does not hand an archived id to a new session. The guarantee is real; only the comment lags. - **`AgentPolicy` grew after M4.** The five read-path and profile-override fields (`allowed_read_signals`, `denied_read_signals`, `allowed_user_attributes`, `denied_user_attributes`, `allowed_profile_overrides`) were added by `d8e4083` for M9/M10 governance. M4 shipped only the four write-path fields.