From f8ca26c3ae7fc5cd9066a16cce3aadf36e79bbd0 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 25 May 2026 12:26:19 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Add private execution event storage","authority":"XY-520"} --- apps/decodex/src/state.rs | 1 + apps/decodex/src/state/internal.rs | 218 ++++++++++++++++++++++- apps/decodex/src/state/models.rs | 60 +++++++ apps/decodex/src/state/store.rs | 91 +++++++++- apps/decodex/src/state/tests.rs | 128 +++++++++++++ docs/reference/operator-control-plane.md | 8 +- docs/spec/agent-evidence.md | 8 + docs/spec/runtime.md | 27 ++- 8 files changed, 529 insertions(+), 12 deletions(-) diff --git a/apps/decodex/src/state.rs b/apps/decodex/src/state.rs index 490df0266..34d2ee907 100644 --- a/apps/decodex/src/state.rs +++ b/apps/decodex/src/state.rs @@ -14,6 +14,7 @@ use std::{ use rusqlite::{Connection, Transaction, params}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ diff --git a/apps/decodex/src/state/internal.rs b/apps/decodex/src/state/internal.rs index d09265f82..1a0e41852 100644 --- a/apps/decodex/src/state/internal.rs +++ b/apps/decodex/src/state/internal.rs @@ -98,6 +98,7 @@ struct StateData { event_summaries: HashMap, worktrees: HashMap, linear_execution_events: HashMap, + private_execution_events: Vec, review_handoffs: HashMap, review_orchestrations: HashMap, dispatch_slot_configs: HashMap, @@ -113,6 +114,7 @@ impl StateData { self.event_summaries = loaded.event_summaries; self.worktrees = loaded.worktrees; self.linear_execution_events = loaded.linear_execution_events; + self.private_execution_events = loaded.private_execution_events; self.review_handoffs = loaded.review_handoffs; self.review_orchestrations = loaded.review_orchestrations; } @@ -184,6 +186,16 @@ impl StateData { attempt.project_id = Some(project_id.to_owned()); } } + + fn next_private_execution_event_id(&self) -> Result { + self.private_execution_events + .iter() + .map(|record| record.record_id) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| eyre::eyre!("Private execution event row id overflowed i64.")) + } } struct SqliteStateStore { @@ -311,12 +323,47 @@ CREATE TABLE IF NOT EXISTS review_orchestrations ( updated_at_unix INTEGER NOT NULL, PRIMARY KEY (project_id, issue_id, branch_name, run_id, attempt_number) ); +"#, + )?; + self.bootstrap_private_execution_events_schema()?; + self.record_schema_version()?; + + Ok(()) + } + + fn bootstrap_private_execution_events_schema(&self) -> Result<()> { + self.connection.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS private_execution_events ( + record_id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + issue_id TEXT NOT NULL, + run_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + recorded_at TEXT NOT NULL, + recorded_at_unix INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS private_execution_events_attempt_idx +ON private_execution_events ( + project_id, issue_id, run_id, attempt_number, record_id +); +"#, + )?; + + Ok(()) + } + + fn record_schema_version(&self) -> Result<()> { + self.connection.execute_batch( + r#" CREATE TABLE IF NOT EXISTS schema_meta ( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL ); INSERT INTO schema_meta (key, value) -VALUES ('schema_version', '4') +VALUES ('schema_version', '5') ON CONFLICT(key) DO UPDATE SET value = excluded.value; "#, )?; @@ -333,6 +380,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; self.load_protocol_event_summaries(&mut state)?; self.load_worktrees(&mut state)?; self.load_linear_execution_events(&mut state)?; + self.load_private_execution_events(&mut state)?; self.load_review_handoffs(&mut state)?; self.load_review_orchestrations(&mut state)?; @@ -348,6 +396,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; persist_protocol_events(&transaction, state)?; persist_worktrees(&transaction, state)?; persist_linear_execution_events(&transaction, state)?; + persist_private_execution_events(&transaction, state)?; persist_review_handoffs(&transaction, state)?; persist_review_orchestrations(&transaction, state)?; @@ -430,6 +479,32 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(()) } + fn insert_private_execution_event( + &self, + record: &PrivateExecutionEventRuntimeRecord, + ) -> Result { + let payload_json = serde_json::to_string(&record.payload)?; + + self.connection.execute( + "INSERT INTO private_execution_events ( + project_id, issue_id, run_id, attempt_number, event_type, payload_json, + recorded_at, recorded_at_unix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + &record.project_id, + &record.issue_id, + &record.run_id, + record.attempt_number, + &record.event_type, + payload_json, + &record.recorded_at, + record.recorded_at_unix, + ], + )?; + + Ok(self.connection.last_insert_rowid()) + } + fn delete_lease(&mut self, issue_id: &str) -> Result<()> { self.connection .execute("DELETE FROM leases WHERE issue_id = ?1", params![issue_id])?; @@ -727,6 +802,57 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(()) } + fn load_private_execution_events(&self, state: &mut StateData) -> Result<()> { + let mut statement = self.connection.prepare( + "SELECT record_id, project_id, issue_id, run_id, attempt_number, event_type, \ + payload_json, recorded_at, recorded_at_unix \ + FROM private_execution_events \ + ORDER BY record_id ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + )) + })?; + + for row in rows { + let ( + record_id, + project_id, + issue_id, + run_id, + attempt_number, + event_type, + payload_json, + recorded_at, + recorded_at_unix, + ) = row?; + let payload = serde_json::from_str::(&payload_json)?; + + state.private_execution_events.push(PrivateExecutionEventRuntimeRecord { + record_id, + project_id, + issue_id, + run_id, + attempt_number, + event_type, + payload, + recorded_at, + recorded_at_unix, + }); + } + + Ok(()) + } + fn load_review_handoffs(&self, state: &mut StateData) -> Result<()> { let mut statement = self.connection.prepare( "SELECT project_id, issue_id, branch_name, run_id, attempt_number, pr_url, \ @@ -902,6 +1028,34 @@ struct LinearExecutionEventRuntimeRecord { recorded_at_unix: i64, } +#[derive(Clone, Debug)] +struct PrivateExecutionEventRuntimeRecord { + record_id: i64, + project_id: String, + issue_id: String, + run_id: String, + attempt_number: i64, + event_type: String, + payload: Value, + recorded_at: String, + recorded_at_unix: i64, +} +impl PrivateExecutionEventRuntimeRecord { + fn as_public(&self) -> PrivateExecutionEvent { + PrivateExecutionEvent { + record_id: self.record_id, + project_id: self.project_id.clone(), + issue_id: self.issue_id.clone(), + run_id: self.run_id.clone(), + attempt_number: self.attempt_number, + event_type: self.event_type.clone(), + payload: self.payload.clone(), + recorded_at: self.recorded_at.clone(), + recorded_at_unix: self.recorded_at_unix, + } + } +} + #[derive(Clone, Debug)] struct WorktreeMappingRecord { project_id: String, @@ -1744,6 +1898,35 @@ fn persist_linear_execution_events( Ok(()) } +fn persist_private_execution_events( + transaction: &Transaction<'_>, + state: &StateData, +) -> Result<()> { + for record in &state.private_execution_events { + let payload_json = serde_json::to_string(&record.payload)?; + + transaction.execute( + "INSERT OR REPLACE INTO private_execution_events ( + record_id, project_id, issue_id, run_id, attempt_number, event_type, + payload_json, recorded_at, recorded_at_unix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + record.record_id, + &record.project_id, + &record.issue_id, + &record.run_id, + record.attempt_number, + &record.event_type, + payload_json, + &record.recorded_at, + record.recorded_at_unix, + ], + )?; + } + + Ok(()) +} + fn persist_review_handoffs(transaction: &Transaction<'_>, state: &StateData) -> Result<()> { for record in state.review_handoffs.values() { transaction.execute( @@ -2312,6 +2495,32 @@ fn parse_linear_execution_event_unix(record: &LinearExecutionEventRecord) -> Opt .map(|timestamp| timestamp.unix_timestamp()) } +fn validate_private_execution_event_inputs( + project_id: &str, + issue_id: &str, + run_id: &str, + attempt_number: i64, + event_type: &str, +) -> Result<()> { + if project_id.trim().is_empty() { + eyre::bail!("Private execution event project_id must not be empty."); + } + if issue_id.trim().is_empty() { + eyre::bail!("Private execution event issue_id must not be empty."); + } + if run_id.trim().is_empty() { + eyre::bail!("Private execution event run_id must not be empty."); + } + if attempt_number < 1 { + eyre::bail!("Private execution event attempt_number must be greater than zero."); + } + if event_type.trim().is_empty() { + eyre::bail!("Private execution event event_type must not be empty."); + } + + Ok(()) +} + fn protocol_event_summary_from_events(events: &[ProtocolEventRecord]) -> ProtocolEventSummaryRecord { let mut summary = ProtocolEventSummaryRecord::default(); @@ -2339,6 +2548,13 @@ fn compare_linear_execution_event_runtime_records( .then_with(|| left.record.idempotency_key.cmp(&right.record.idempotency_key)) } +fn compare_private_execution_event_runtime_records( + left: &PrivateExecutionEventRuntimeRecord, + right: &PrivateExecutionEventRuntimeRecord, +) -> cmp::Ordering { + left.record_id.cmp(&right.record_id) +} + fn compare_project_run_status(left: &ProjectRunStatus, right: &ProjectRunStatus) -> cmp::Ordering { right .active_lease diff --git a/apps/decodex/src/state/models.rs b/apps/decodex/src/state/models.rs index b874cd580..c4e2bdc03 100644 --- a/apps/decodex/src/state/models.rs +++ b/apps/decodex/src/state/models.rs @@ -70,6 +70,66 @@ impl RunAttempt { } } +/// One private, local-only execution event retained in the runtime SQLite ledger. +#[derive(Clone, Debug, PartialEq)] +pub struct PrivateExecutionEvent { + record_id: i64, + project_id: String, + issue_id: String, + run_id: String, + attempt_number: i64, + event_type: String, + payload: Value, + recorded_at: String, + recorded_at_unix: i64, +} +impl PrivateExecutionEvent { + /// Monotonic local row id assigned by the runtime store. + pub fn record_id(&self) -> i64 { + self.record_id + } + + /// Local project identifier owning the evidence row. + pub fn project_id(&self) -> &str { + &self.project_id + } + + /// Issue identifier for this private evidence row. + pub fn issue_id(&self) -> &str { + &self.issue_id + } + + /// Run identifier for this private evidence row. + pub fn run_id(&self) -> &str { + &self.run_id + } + + /// Attempt number for this private evidence row. + pub fn attempt_number(&self) -> i64 { + self.attempt_number + } + + /// Private event type chosen by the runtime or issue-scoped tool path. + pub fn event_type(&self) -> &str { + &self.event_type + } + + /// Structured JSON payload kept local to the runtime store. + pub fn payload(&self) -> &Value { + &self.payload + } + + /// UTC timestamp when the runtime store recorded this row. + pub fn recorded_at(&self) -> &str { + &self.recorded_at + } + + /// Unix timestamp when the runtime store recorded this row. + pub fn recorded_at_unix(&self) -> i64 { + self.recorded_at_unix + } +} + /// Project-scoped operator view of one run attempt. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectRunStatus { diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index 37aa05400..c01d9b6b7 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -47,7 +47,7 @@ impl From for DispatchSlotLimit { } } -/// Local runtime store for leases, attempts, worktrees, and protocol events. +/// Local runtime store for leases, attempts, worktrees, protocol events, and private evidence. #[derive(Default)] pub struct StateStore { inner: Mutex, @@ -171,6 +171,13 @@ impl StateStore { { attempt.issue_id = canonical_issue_id.to_owned(); } + for record in state + .private_execution_events + .iter_mut() + .filter(|record| record.issue_id == previous_issue_id) + { + record.issue_id = canonical_issue_id.to_owned(); + } self.persist_runtime_state_locked(&state)?; @@ -960,6 +967,74 @@ impl StateStore { Ok(records.into_iter().map(|record| record.record).collect()) } + /// Append one private execution event to the local runtime evidence ledger. + pub fn append_private_execution_event( + &self, + project_id: &str, + issue_id: &str, + run_id: &str, + attempt_number: i64, + event_type: &str, + payload: Value, + ) -> Result { + validate_private_execution_event_inputs( + project_id, + issue_id, + run_id, + attempt_number, + event_type, + )?; + + let now = timestamp_parts(); + let mut state = self.lock_without_refresh()?; + let mut record = PrivateExecutionEventRuntimeRecord { + record_id: 0, + project_id: project_id.to_owned(), + issue_id: issue_id.to_owned(), + run_id: run_id.to_owned(), + attempt_number, + event_type: event_type.to_owned(), + payload, + recorded_at: now.text, + recorded_at_unix: now.unix, + }; + + record.record_id = match self.insert_private_execution_event_locked(&record)? { + Some(record_id) => record_id, + None => state.next_private_execution_event_id()?, + }; + + state.private_execution_events.push(record.clone()); + + Ok(record.as_public()) + } + + /// List private execution events for one project/issue/run/attempt tuple. + pub fn list_private_execution_events( + &self, + project_id: &str, + issue_id: &str, + run_id: &str, + attempt_number: i64, + ) -> Result> { + let state = self.lock()?; + let mut records = state + .private_execution_events + .iter() + .filter(|record| { + record.project_id == project_id + && record.issue_id == issue_id + && record.run_id == run_id + && record.attempt_number == attempt_number + }) + .cloned() + .collect::>(); + + records.sort_by(compare_private_execution_event_runtime_records); + + Ok(records.into_iter().map(|record| record.as_public()).collect()) + } + /// Count protocol journal records for one run. pub fn event_count(&self, run_id: &str) -> Result { let state = self.lock()?; @@ -1272,6 +1347,20 @@ impl StateStore { sqlite.delete_linear_execution_event(idempotency_key) } + fn insert_private_execution_event_locked( + &self, + record: &PrivateExecutionEventRuntimeRecord, + ) -> Result> { + let Some(sqlite) = self.sqlite.as_ref() else { + return Ok(None); + }; + let sqlite = sqlite + .lock() + .map_err(|_| eyre::eyre!("StateStore SQLite mutex is poisoned."))?; + + sqlite.insert_private_execution_event(record).map(Some) + } + fn delete_lease_locked(&self, issue_id: &str) -> Result<()> { let Some(sqlite) = self.sqlite.as_ref() else { return Ok(()); diff --git a/apps/decodex/src/state/tests.rs b/apps/decodex/src/state/tests.rs index 73dc68b38..1cba6a388 100644 --- a/apps/decodex/src/state/tests.rs +++ b/apps/decodex/src/state/tests.rs @@ -1802,6 +1802,134 @@ fn state_store_open_persists_runtime_history_across_instances() { assert_eq!(ledger_records, vec![ledger_record]); } +#[test] +fn private_execution_events_persist_reload_and_keep_append_order() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("runtime.sqlite3"); + let store = StateStore::open(&state_path).expect("state store should open"); + let first = store + .append_private_execution_event( + "decodex", + "XY-520", + "run-1", + 2, + "evidence_snapshot", + serde_json::json!({ + "summary": "first private snapshot", + "evidence": ["runtime-db", "local-only"], + }), + ) + .expect("first private event should append"); + let second = store + .append_private_execution_event( + "decodex", + "XY-520", + "run-1", + 2, + "review_pass", + serde_json::json!({ + "summary": "second private snapshot", + "outcome": "clean", + }), + ) + .expect("second private event should append"); + + assert!( + first.record_id() < second.record_id(), + "private event row ids should preserve append order" + ); + + let reopened = StateStore::open(&state_path).expect("state store should reopen"); + let events = reopened + .list_private_execution_events("decodex", "XY-520", "run-1", 2) + .expect("private events should reload"); + + assert_eq!(events.len(), 2); + assert_eq!(events[0].record_id(), first.record_id()); + assert_eq!(events[0].project_id(), "decodex"); + assert_eq!(events[0].issue_id(), "XY-520"); + assert_eq!(events[0].run_id(), "run-1"); + assert_eq!(events[0].attempt_number(), 2); + assert_eq!(events[0].event_type(), "evidence_snapshot"); + assert_eq!(events[0].payload()["evidence"], serde_json::json!(["runtime-db", "local-only"])); + assert_eq!(events[1].record_id(), second.record_id()); + assert_eq!(events[1].event_type(), "review_pass"); + assert_eq!(events[1].payload()["outcome"], serde_json::json!("clean")); + assert!(events[0].recorded_at_unix() <= events[1].recorded_at_unix()); + assert!(!events[0].recorded_at().is_empty()); +} + +#[test] +fn private_execution_events_filter_issue_run_attempt_and_stay_out_of_linear_cache() { + let store = StateStore::open_in_memory().expect("in-memory state store should open"); + + store + .append_private_execution_event( + "decodex", + "XY-520", + "run-1", + 1, + "kept", + serde_json::json!({"match": true}), + ) + .expect("matching private event should append"); + store + .append_private_execution_event( + "decodex", + "XY-521", + "run-1", + 1, + "other_issue", + serde_json::json!({"match": false}), + ) + .expect("other issue private event should append"); + store + .append_private_execution_event( + "decodex", + "XY-520", + "run-2", + 1, + "other_run", + serde_json::json!({"match": false}), + ) + .expect("other run private event should append"); + store + .append_private_execution_event( + "decodex", + "XY-520", + "run-1", + 2, + "other_attempt", + serde_json::json!({"match": false}), + ) + .expect("other attempt private event should append"); + store + .append_private_execution_event( + "pubfi", + "XY-520", + "run-1", + 1, + "other_project", + serde_json::json!({"match": false}), + ) + .expect("other project private event should append"); + + let events = store + .list_private_execution_events("decodex", "XY-520", "run-1", 1) + .expect("private events should list"); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type(), "kept"); + assert_eq!(events[0].payload()["match"], serde_json::json!(true)); + assert!( + store + .list_linear_execution_events("decodex", "XY-520") + .expect("linear event cache should read") + .is_empty(), + "private execution events must not populate the public Linear mirror cache" + ); +} + #[test] fn state_store_open_refreshes_pubfi_project_registry_across_instances() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 118cf20a5..1e89e48d7 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -58,10 +58,10 @@ Use `decodex diagnose --json` when an agent needs the current handoff index dire | Surface | Owns | Does Not Own | | --- | --- | --- | -| Runtime SQLite DB | active leases, attempts, protocol events, worktree mappings, retry state, retained PR state, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | +| Runtime SQLite DB | active leases, attempts, protocol events, private execution events, worktree mappings, retry state, retained PR state, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | | Central project config | `service_id`, repo root, worktree root, tracker/GitHub credential env-var names, enabled project registration | per-run state or issue ownership | | Project `WORKFLOW.md` | repo policy, validation gate, state names, retry/review policy | runtime ownership, queue labels, credentials, model overrides | -| Linear | team-visible issue state, queue/active/manual-attention labels, coarse execution ledger comments, progress/failure/handoff/closeout summaries | high-frequency runtime truth, heartbeat, token pressure, raw attempts, connector retry budgets | +| Linear | team-visible issue state, queue/active/manual-attention labels, coarse execution ledger comments, progress/failure/handoff/closeout summaries | high-frequency runtime truth, heartbeat, token pressure, raw attempts, private execution evidence, connector retry budgets | | GitHub | PR, checks, review comments, merge evidence, signed commit verification | queue selection or local lane ownership | | `.decodex-run-activity` | short-lived child activity heartbeat for the active attempt, including same-boot and same-process-start liveness | durable ownership, review handoff identity, cleanup authority | @@ -253,8 +253,10 @@ rate-limited, or unavailable. from local runtime DB state while external sync is paused. - Linear writes should stay coarse: one run-start ledger, material progress checkpoints, PR-ready/handoff, blocked/failed, landed, done, and cleanup summaries. + Full structured execution evidence belongs in private runtime SQLite events. - Fine-grained retry budgets, raw attempts, heartbeat, child buckets, token pressure, - and recovery details stay local. + recovery details, and process logs stay local. Logs are diagnostic text; private + execution events are structured runtime evidence. - Completed lanes without Decodex Linear execution ledger records are reported as `missing` / `execution_ledger_missing`. Tracker terminal state, local attempt success, and non-ledger comments never satisfy the Run Ledger outcome contract. diff --git a/docs/spec/agent-evidence.md b/docs/spec/agent-evidence.md index 4086f787c..c2251d20d 100644 --- a/docs/spec/agent-evidence.md +++ b/docs/spec/agent-evidence.md @@ -25,6 +25,12 @@ diagnosis. Agent evidence is a stable file projection of that snapshot so a repa agent can start from one compact index instead of reconstructing state from logs, Markdown notes, worktree names, and ad hoc SQL. +Private execution events are structured rows in the runtime SQLite database scoped by +project, issue, run, and attempt. They are the local-only ledger for full execution +evidence that should not be mirrored to Linear. Agent evidence files may point agents +toward the current runtime context, but they do not replace the private execution +event store. + ## Path Layout Agent evidence lives under the local Decodex home: @@ -39,6 +45,8 @@ Agent evidence lives under the local Decodex home: These files are local-only operator state. They are not committed to target repositories, mirrored to Linear, or used as external collaboration records. +They are also not process logs; logs stay diagnostic, while private execution events +stay structured runtime evidence in SQLite. ## Write Triggers diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 08fa583ca..894256519 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -31,10 +31,14 @@ Defines: The runtime scope, source-of-truth boundaries, eligibility rules, lane ## Source of truth boundaries -- The Decodex runtime SQLite database is the single-machine source of truth for active leases, attempts, protocol events, worktree mappings, retained PR state, retry state, phase timing, project registration, tracker cache, PR cache, and connector backoff. +- The Decodex runtime SQLite database is the single-machine source of truth for active leases, attempts, protocol events, private execution events, worktree mappings, retained PR state, retry state, phase timing, project registration, tracker cache, PR cache, and connector backoff. - Linear remains the team-visible tracker surface for issue lifecycle, queue/active/manual-attention labels, and coarse lifecycle summaries such as start, PR-ready, blocked, failed, landed, and done. - Versioned Linear execution event comments use the schema in [`linear-execution-ledger.md`](./linear-execution-ledger.md), but fine-grained runtime truth must not be rebuilt from comments every tick. +- Private execution events are structured runtime evidence rows scoped by + `project_id`, `issue_id`, `run_id`, and `attempt_number`. They hold full local + evidence that should be queryable through `StateStore` without being mirrored to + Linear execution ledger payloads. - Centralized project directories under `~/.codex/decodex/projects//` form the project contract. Each directory contains `project.toml` for service paths and credentials plus `WORKFLOW.md` for execution policy. They do not store @@ -49,6 +53,7 @@ The following facts are local runtime truth and must not be rebuilt from Linear - lane attempts: `run_id`, `attempt_number`, attempt status, and terminal classification - protocol events, event counts, event timestamps, and thread/liveness hydration fields +- private execution events carrying structured local evidence for an issue/run/attempt - retry and backoff state: queued retry kind, due time, retry budget, and connector backoff - phase timing and operator activity summaries - retained worktree mappings, retained PR handoff identity, post-review phase, and cleanup or repair ownership @@ -306,6 +311,7 @@ The runtime database stores at least: - active leases and dispatch ownership - run attempts and attempt status - protocol event journals +- private execution events scoped by project, issue, run, and attempt - worktree mappings - retained PR and post-review state - retry state and retry budgets @@ -339,6 +345,11 @@ The minimum supported surface is: - a local status command that renders the current service snapshot in both human-readable and JSON forms - an agent evidence command, `decodex diagnose`, that writes a compact derived handoff index, blocker snapshots, run capsules, and an append-only evidence event stream under `~/.codex/decodex/agent-evidence//` +Structured logs remain diagnostic. They may help explain a live failure, but they are +not the structured private evidence ledger. Private execution events belong in the +runtime SQLite store; Linear execution events remain the constrained public mirror for +coarse lifecycle records. + The status surface should describe runtime DB-backed execution state, plus low-frequency connector refreshes and retained `.worktrees` lanes, for example: - active leased runs @@ -355,12 +366,14 @@ After a process restart, recent-run history, active lease ownership, retained po ## Retention and cleanup - Lease and session mappings: remove when the run closes. -- Attempt records, terminal outcome, and locally cached Linear execution ledger links - remain runtime history. Raw protocol event rows for terminal runs may be compacted by - `decodex maintenance prune --apply` once the latest event is at least 14 days old, - but only after Decodex writes the compact run summary and confirms that no active - lease, retained worktree, review handoff, review orchestration, or cleanup blocker - still owns that run or issue. +- Attempt records, terminal outcome, private execution events, and locally cached + Linear execution ledger links remain runtime history. Raw protocol event rows for + terminal runs may be compacted by `decodex maintenance prune --apply` once the + latest event is at least 14 days old, but only after Decodex writes the compact run + summary and confirms that no active lease, retained worktree, review handoff, review + orchestration, or cleanup blocker still owns that run or issue. The first private + execution event schema has no compaction path; add one only when runtime maintenance + owns a concrete retention policy for that structured evidence. - `decodex maintenance prune --dry-run` is the read-only retention path for inspecting local cleanup candidates without applying retention changes. The `--apply` mode owns state-aware protocol-event