Skip to main content

licoup_workflow_store/
store.rs

1use anyhow::{Result, anyhow, ensure};
2use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
3use serde_json::Value;
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6use std::fs;
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10use super::{
11    BindingCandidate, BindingValue, STRATEGY_SCHEMA_VERSION, StrategyAuthorization,
12    StrategyDefinition, StrategyDefinitionSummary, StrategyDiagnostic, StrategyProjection,
13};
14use crate::ASSISTANT_TEMPORARY_DEFINITION_PREFIX;
15use licoup_foundation::core::sqlite_contract::ColumnVariant;
16use licoup_workflow::{
17    BindingKind, CommandStatus, FailureClass, GraphState, GraphStateKind, ReducerEvent, RunCommand,
18    RunSnapshot, StrategyRunStatus, Transition, TransitionEvent, TransitionMode,
19    WorkflowDefinition, compile_workflow, reduce,
20};
21
22const DATABASE_FILE: &str = "strategies.sqlite3";
23
24#[derive(Clone, Debug)]
25pub struct StrategyStore {
26    db_path: PathBuf,
27    package_revisions_root: Option<PathBuf>,
28}
29
30impl StrategyStore {
31    pub fn open(portable_root: &Path) -> Result<Self> {
32        let root = portable_root.join("client-state").join("adaptive-flywheel");
33        let db_path = root.join(DATABASE_FILE);
34        preflight_existing_store(&db_path)?;
35        licoup_foundation::platform::file_security::ensure_private_dir(
36            &portable_root.join("client-state"),
37        )?;
38        licoup_foundation::platform::file_security::ensure_private_dir(&root)?;
39        let existed = db_path.exists();
40        let store = Self {
41            db_path,
42            package_revisions_root: Some(root.join("strategy-packages").join("revisions")),
43        };
44        let retired = store.with_connection(|connection| {
45            if existed {
46                validate_current_schema(connection).map(|()| Vec::new())
47            } else {
48                initialize_schema(connection)
49            }
50        })?;
51        licoup_foundation::platform::file_security::harden_private_path(&store.db_path)?;
52        store.remove_retired_revision_trees(&retired);
53        Ok(store)
54    }
55
56    pub fn open_for_migration(portable_root: &Path) -> Result<Self> {
57        let root = portable_root.join("client-state").join("adaptive-flywheel");
58        // Native admission validates the published physical input before this
59        // private conversion entry. Keep the owner's lower-level transforms
60        // testable without turning them into additional admitted endpoints.
61        licoup_foundation::platform::file_security::ensure_private_dir(
62            &portable_root.join("client-state"),
63        )?;
64        licoup_foundation::platform::file_security::ensure_private_dir(&root)?;
65        let store = Self {
66            db_path: root.join(DATABASE_FILE),
67            package_revisions_root: Some(root.join("strategy-packages").join("revisions")),
68        };
69        let retired = store.with_connection(initialize_schema)?;
70        licoup_foundation::platform::file_security::harden_private_path(&store.db_path)?;
71        store.remove_retired_revision_trees(&retired);
72        Ok(store)
73    }
74
75    pub fn open_in_memory() -> Result<Self> {
76        let path =
77            std::env::temp_dir().join(format!("lico-adaptive-flywheel-{}.sqlite3", Uuid::new_v4()));
78        let store = Self {
79            db_path: path,
80            package_revisions_root: None,
81        };
82        store.with_connection(initialize_schema)?;
83        Ok(store)
84    }
85
86    pub fn db_path(&self) -> &Path {
87        &self.db_path
88    }
89
90    pub fn durable_queue(&self) -> super::DurableQueue {
91        super::DurableQueue::from_store(self.clone())
92    }
93
94    pub fn durable_subscriptions(&self) -> super::DurableSubscriptionStore {
95        super::DurableSubscriptionStore::from_store(self.clone())
96    }
97
98    pub fn durable_control(&self) -> super::DurableControlledStore {
99        super::DurableControlledStore::from_store(self.clone())
100    }
101
102    pub fn with_connection<T>(
103        &self,
104        operation: impl FnOnce(&mut Connection) -> Result<T>,
105    ) -> Result<T> {
106        self.with_connection_typed(operation)
107    }
108
109    pub(crate) fn with_connection_typed<T, E>(
110        &self,
111        operation: impl FnOnce(&mut Connection) -> std::result::Result<T, E>,
112    ) -> std::result::Result<T, E>
113    where
114        E: From<anyhow::Error>,
115    {
116        let mut connection = Connection::open(&self.db_path)
117            .map_err(|_| E::from(anyhow!("strategy_database_open_failed")))?;
118        configure_connection(&connection).map_err(E::from)?;
119        operation(&mut connection)
120    }
121
122    pub fn register_definition(
123        &self,
124        revision_digest: &str,
125        semantics_digest: &str,
126        workflow: &WorkflowDefinition,
127        asset_count: usize,
128        imported_at_unix_ms: i64,
129    ) -> Result<StrategyDefinition> {
130        let compiled = compile_workflow(workflow.clone())?;
131        let workflow_json = serde_json::to_string(compiled.definition())?;
132        self.with_connection(|connection| {
133            let transaction =
134                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
135            transaction.execute(
136                "INSERT INTO strategy_definitions(
137                   definition_id, revision_digest, semantics_digest, name, version,
138                   workflow_json, asset_count, imported_at
139                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
140                 ON CONFLICT(revision_digest) DO NOTHING",
141                params![
142                    compiled.definition().metadata.id,
143                    revision_digest,
144                    semantics_digest,
145                    compiled.definition().metadata.name,
146                    compiled.definition().metadata.version,
147                    workflow_json,
148                    asset_count as i64,
149                    imported_at_unix_ms,
150                ],
151            )?;
152            let existing: Option<(String, String)> = transaction
153                .query_row(
154                    "SELECT definition_id, semantics_digest FROM strategy_definitions
155                     WHERE revision_digest=?1",
156                    params![revision_digest],
157                    |row| Ok((row.get(0)?, row.get(1)?)),
158                )
159                .optional()?;
160            ensure!(
161                existing
162                    == Some((
163                        compiled.definition().metadata.id.clone(),
164                        semantics_digest.to_owned()
165                    )),
166                "strategy_revision_conflict"
167            );
168            transaction.commit()?;
169            self.definition_by_revision(revision_digest)
170        })
171    }
172
173    pub fn list_definitions(&self) -> Result<Vec<StrategyDefinitionSummary>> {
174        self.with_connection(|connection| {
175            let mut statement = connection.prepare(
176                "SELECT definition_id, revision_digest, semantics_digest, name, version, imported_at,
177                        EXISTS(
178                          SELECT 1 FROM strategy_authorizations
179                          WHERE revision_digest=strategy_definitions.revision_digest AND active=1
180                        )
181                 FROM strategy_definitions
182                 WHERE definition_id NOT LIKE 'assistant-temporary%'
183                 ORDER BY imported_at DESC, revision_digest ASC",
184            )?;
185            let rows = statement.query_map([], summary_from_row)?;
186            rows.collect::<rusqlite::Result<Vec<_>>>()
187                .map_err(Into::into)
188        })
189    }
190
191    pub fn definition_by_revision(&self, revision_digest: &str) -> Result<StrategyDefinition> {
192        self.with_connection(|connection| definition_by_revision(connection, revision_digest))
193    }
194
195    pub fn latest_definition(&self, definition_id: &str) -> Result<StrategyDefinition> {
196        self.with_connection(|connection| {
197            let revision: Option<String> = connection
198                .query_row(
199                    "SELECT revision_digest FROM strategy_definitions
200                     WHERE definition_id=?1 ORDER BY imported_at DESC, revision_digest ASC LIMIT 1",
201                    params![definition_id],
202                    |row| row.get(0),
203                )
204                .optional()?;
205            definition_by_revision(
206                connection,
207                revision
208                    .as_deref()
209                    .ok_or_else(|| anyhow!("strategy_definition_not_found"))?,
210            )
211        })
212    }
213
214    pub fn update_binding(
215        &self,
216        revision_digest: &str,
217        slot_id: &str,
218        value_id: &str,
219        model: &str,
220        reasoning_effort: &str,
221        expected_revision: Option<u64>,
222    ) -> Result<BindingValue> {
223        self.replace_slot_bindings(
224            revision_digest,
225            slot_id,
226            &[BindingCandidate {
227                value_id: value_id.to_owned(),
228                model: model.to_owned(),
229                reasoning_effort: reasoning_effort.to_owned(),
230            }],
231            expected_revision,
232        )?
233        .into_iter()
234        .next()
235        .ok_or_else(|| anyhow!("strategy_binding_incomplete"))
236    }
237
238    pub fn replace_slot_bindings(
239        &self,
240        revision_digest: &str,
241        slot_id: &str,
242        candidates: &[BindingCandidate],
243        expected_revision: Option<u64>,
244    ) -> Result<Vec<BindingValue>> {
245        validate_opaque_id(slot_id, "strategy_binding_slot_invalid")?;
246        ensure!(candidates.len() <= 16, "strategy_binding_limit");
247        for candidate in candidates {
248            validate_opaque_id(&candidate.value_id, "strategy_binding_value_invalid")?;
249            validate_optional_text(&candidate.model, "strategy_binding_model_invalid")?;
250            validate_optional_text(
251                &candidate.reasoning_effort,
252                "strategy_binding_reasoning_effort_invalid",
253            )?;
254        }
255        let definition = self.definition_by_revision(revision_digest)?;
256        ensure!(
257            definition
258                .workflow
259                .actor_slots
260                .iter()
261                .any(|slot| slot.id == slot_id),
262            "strategy_binding_slot_unknown"
263        );
264        self.with_connection(|connection| {
265            let transaction =
266                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
267            let current: Option<u64> = transaction
268                .query_row(
269                    "SELECT MAX(revision) FROM strategy_bindings
270                     WHERE revision_digest=?1 AND slot_id=?2",
271                    params![revision_digest, slot_id],
272                    |row| row.get::<_, Option<i64>>(0),
273                )
274                .optional()?
275                .flatten()
276                .map(|value| value as u64);
277            if let Some(expected) = expected_revision {
278                ensure!(current.unwrap_or(0) == expected, "strategy_revision_conflict");
279            }
280            let revision = current.unwrap_or(0) + 1;
281            transaction.execute(
282                "DELETE FROM strategy_bindings WHERE revision_digest=?1 AND slot_id=?2",
283                params![revision_digest, slot_id],
284            )?;
285            let mut values = Vec::with_capacity(candidates.len());
286            for (ordinal, candidate) in candidates.iter().enumerate() {
287                let ordinal = u8::try_from(ordinal).map_err(|_| anyhow!("strategy_binding_limit"))?;
288                transaction.execute(
289                    "INSERT INTO strategy_bindings(
290                       revision_digest, slot_id, ordinal, value_id, model, reasoning_effort, revision
291                     ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
292                    params![
293                        revision_digest,
294                        slot_id,
295                        ordinal as i64,
296                        candidate.value_id,
297                        candidate.model,
298                        candidate.reasoning_effort,
299                        revision as i64
300                    ],
301                )?;
302                values.push(BindingValue {
303                    slot_id: slot_id.to_owned(),
304                    ordinal,
305                    value_id: candidate.value_id.clone(),
306                    model: candidate.model.clone(),
307                    reasoning_effort: candidate.reasoning_effort.clone(),
308                    revision,
309                });
310            }
311            transaction.execute(
312                "UPDATE strategy_authorizations SET active=0 WHERE revision_digest=?1 AND active=1",
313                params![revision_digest],
314            )?;
315            transaction.commit()?;
316            Ok(values)
317        })
318    }
319
320    pub fn remove_binding(
321        &self,
322        revision_digest: &str,
323        slot_id: &str,
324        expected_revision: Option<u64>,
325    ) -> Result<()> {
326        validate_opaque_id(slot_id, "strategy_binding_slot_invalid")?;
327        self.with_connection(|connection| {
328            let transaction =
329                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
330            let current: Option<u64> = transaction
331                .query_row(
332                    "SELECT MAX(revision) FROM strategy_bindings
333                     WHERE revision_digest=?1 AND slot_id=?2",
334                    params![revision_digest, slot_id],
335                    |row| row.get::<_, Option<i64>>(0),
336                )
337                .optional()?
338                .flatten()
339                .map(|value| value as u64);
340            if let Some(expected) = expected_revision {
341                ensure!(
342                    current.unwrap_or(0) == expected,
343                    "strategy_revision_conflict"
344                );
345            }
346            transaction.execute(
347                "DELETE FROM strategy_bindings WHERE revision_digest=?1 AND slot_id=?2",
348                params![revision_digest, slot_id],
349            )?;
350            transaction.execute(
351                "UPDATE strategy_authorizations SET active=0
352                 WHERE revision_digest=?1 AND active=1",
353                params![revision_digest],
354            )?;
355            transaction.commit()?;
356            Ok(())
357        })
358    }
359
360    pub fn authorization_preview(&self, revision_digest: &str) -> Result<StrategyAuthorization> {
361        let definition = self.definition_by_revision(revision_digest)?;
362        ensure!(
363            bindings_complete(&definition),
364            "strategy_binding_incomplete"
365        );
366        let binding_digest = binding_digest(&definition.bindings)?;
367        let next_revision = definition
368            .authorization
369            .as_ref()
370            .map_or(1, |authorization| authorization.revision + 1);
371        let authorization_digest = authorization_digest(
372            revision_digest,
373            &definition.summary.semantics_digest,
374            &binding_digest,
375            next_revision,
376        );
377        Ok(StrategyAuthorization {
378            definition_digest: revision_digest.to_owned(),
379            semantics_digest: definition.summary.semantics_digest,
380            binding_digest,
381            authorization_digest,
382            revision: next_revision,
383            active: false,
384        })
385    }
386
387    pub fn grant_authorization(
388        &self,
389        revision_digest: &str,
390        expected_authorization_digest: &str,
391    ) -> Result<StrategyAuthorization> {
392        self.with_connection(|connection| {
393            let transaction =
394                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
395            let definition = definition_by_revision(&transaction, revision_digest)?;
396            ensure!(
397                bindings_complete(&definition),
398                "strategy_binding_incomplete"
399            );
400            let binding_digest = binding_digest(&definition.bindings)?;
401            let next_revision = definition
402                .authorization
403                .as_ref()
404                .map_or(1, |authorization| authorization.revision + 1);
405            let authorization_digest = authorization_digest(
406                revision_digest,
407                &definition.summary.semantics_digest,
408                &binding_digest,
409                next_revision,
410            );
411            ensure!(
412                authorization_digest == expected_authorization_digest,
413                "strategy_authorization_stale"
414            );
415            transaction.execute(
416                "UPDATE strategy_authorizations SET active=0 WHERE revision_digest=?1 AND active=1",
417                params![revision_digest],
418            )?;
419            transaction.execute(
420                "INSERT INTO strategy_authorizations(
421                   revision_digest, revision, semantics_digest, binding_digest,
422                   authorization_digest, active, created_at
423                 ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)",
424                params![
425                    revision_digest,
426                    next_revision as i64,
427                    definition.summary.semantics_digest,
428                    binding_digest,
429                    authorization_digest,
430                    now_ms(),
431                ],
432            )?;
433            transaction.commit()?;
434            Ok(StrategyAuthorization {
435                definition_digest: revision_digest.to_owned(),
436                semantics_digest: definition.summary.semantics_digest,
437                binding_digest,
438                authorization_digest,
439                revision: next_revision,
440                active: true,
441            })
442        })
443    }
444
445    pub fn revoke_authorization(&self, revision_digest: &str) -> Result<()> {
446        self.with_connection(|connection| {
447            connection.execute(
448                "UPDATE strategy_authorizations SET active=0 WHERE revision_digest=?1 AND active=1",
449                params![revision_digest],
450            )?;
451            Ok(())
452        })
453    }
454
455    pub fn start_run(
456        &self,
457        revision_digest: &str,
458        input: Value,
459        idempotency_key: &str,
460        conversation_id: Option<&str>,
461        cwd: Option<String>,
462    ) -> Result<RunSnapshot> {
463        self.start_run_with_assistant_context(
464            revision_digest,
465            input,
466            idempotency_key,
467            conversation_id,
468            cwd,
469            None,
470            None,
471        )
472    }
473
474    pub(crate) fn start_run_with_assistant_context(
475        &self,
476        revision_digest: &str,
477        input: Value,
478        idempotency_key: &str,
479        conversation_id: Option<&str>,
480        cwd: Option<String>,
481        assistant_membership_id: Option<&str>,
482        route_receipt: Option<Value>,
483    ) -> Result<RunSnapshot> {
484        validate_opaque_id(idempotency_key, "strategy_idempotency_key_invalid")?;
485        if let Some(conversation_id) = conversation_id {
486            validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
487        }
488        if let Some(membership_id) = assistant_membership_id {
489            validate_opaque_id(membership_id, "strategy_membership_id_invalid")?;
490        }
491        let input_bytes = serde_json::to_vec(&input)?;
492        let mut digest_source = Vec::from(revision_digest.as_bytes());
493        digest_source.push(0);
494        digest_source.extend_from_slice(&input_bytes);
495        digest_source.push(0);
496        digest_source.extend_from_slice(conversation_id.unwrap_or("").as_bytes());
497        digest_source.push(0);
498        digest_source.extend_from_slice(cwd.as_deref().unwrap_or("").as_bytes());
499        digest_source.push(0);
500        digest_source.extend_from_slice(assistant_membership_id.unwrap_or("").as_bytes());
501        digest_source.push(0);
502        digest_source.extend_from_slice(&serde_json::to_vec(&route_receipt)?);
503        let request_digest = sha256_hex(&digest_source);
504        self.with_connection(|connection| {
505            let transaction =
506                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
507            let existing: Option<(String, String)> = transaction
508                .query_row(
509                    "SELECT run_id, request_digest FROM strategy_runs WHERE idempotency_key=?1",
510                    params![idempotency_key],
511                    |row| Ok((row.get(0)?, row.get(1)?)),
512                )
513                .optional()?;
514            if let Some((run_id, existing_digest)) = existing {
515                ensure!(
516                    existing_digest == request_digest,
517                    "strategy_idempotency_conflict"
518                );
519                let snapshot = load_run(&transaction, &run_id)?;
520                transaction.commit()?;
521                return Ok(snapshot);
522            }
523
524            // Authorization, exact bindings, reduction, and insertion share
525            // one write transaction. A concurrent revoke or binding update
526            // therefore happens wholly before or wholly after this admission.
527            let definition = definition_by_revision(&transaction, revision_digest)?;
528            ensure!(
529                bindings_complete(&definition),
530                "strategy_binding_incomplete"
531            );
532            let authorization = current_authorization(&definition)?;
533            ensure!(
534                authorization.binding_digest == binding_digest(&definition.bindings)?,
535                "strategy_authorization_stale"
536            );
537            let slot_candidate_counts = slot_candidate_counts(&definition.bindings);
538            let semantics_digest = definition.summary.semantics_digest.clone();
539            let compiled = compile_workflow(definition.workflow)?;
540            let run_id = format!("run-{}", Uuid::new_v4());
541            let empty = RunSnapshot::empty(&run_id, revision_digest, &semantics_digest);
542            let event = ReducerEvent::Start { input };
543            let output = reduce(&compiled, &empty, event.clone())?;
544            let mut snapshot = output.snapshot;
545            snapshot.conversation_id = conversation_id.map(str::to_owned);
546            snapshot.assistant_membership_id = assistant_membership_id.map(str::to_owned);
547            snapshot.route_receipt = route_receipt;
548            snapshot.cwd = cwd;
549            snapshot.slot_candidate_counts = slot_candidate_counts;
550            let now = now_ms();
551            transaction.execute(
552                "INSERT INTO strategy_runs(
553                   run_id, revision_digest, semantics_digest, idempotency_key,
554                   request_digest, snapshot_json, conversation_id, terminal,
555                   created_at, updated_at
556                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
557                params![
558                    run_id,
559                    revision_digest,
560                    semantics_digest,
561                    idempotency_key,
562                    request_digest,
563                    serde_json::to_string(&snapshot)?,
564                    snapshot.conversation_id,
565                    i64::from(run_is_terminal(snapshot.status)),
566                    now,
567                ],
568            )?;
569            persist_event_and_commands(
570                &transaction,
571                &empty,
572                &snapshot,
573                &event,
574                &output.emitted_commands,
575                now,
576            )?;
577            transaction.commit()?;
578            Ok(snapshot)
579        })
580    }
581
582    /// Atomically freezes one Assistant-temporary definition, exact
583    /// Membership bindings, authorization and run snapshot. The definition
584    /// digest already commits to the route receipt; existing rows must be
585    /// byte-for-byte equivalent and can never be rebound.
586    #[allow(clippy::too_many_arguments)]
587    pub fn admit_assistant_run(
588        &self,
589        revision_digest: &str,
590        semantics_digest: &str,
591        workflow: &WorkflowDefinition,
592        bindings: &[BindingValue],
593        input: Value,
594        idempotency_key: &str,
595        conversation_id: &str,
596        assistant_membership_id: &str,
597        route_receipt: Value,
598    ) -> Result<(RunSnapshot, bool)> {
599        validate_opaque_id(idempotency_key, "strategy_idempotency_key_invalid")?;
600        validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
601        validate_opaque_id(assistant_membership_id, "strategy_membership_id_invalid")?;
602        ensure!(
603            workflow
604                .metadata
605                .id
606                .starts_with(ASSISTANT_TEMPORARY_DEFINITION_PREFIX),
607            "graph_identity_rejected"
608        );
609        let compiled = compile_workflow(workflow.clone())?;
610        let workflow_json = serde_json::to_string(compiled.definition())?;
611        let mut expected_bindings = bindings.to_vec();
612        expected_bindings.sort_by(|left, right| {
613            left.slot_id
614                .cmp(&right.slot_id)
615                .then_with(|| left.ordinal.cmp(&right.ordinal))
616        });
617        for binding in &expected_bindings {
618            validate_opaque_id(&binding.slot_id, "strategy_binding_slot_invalid")?;
619            validate_opaque_id(&binding.value_id, "strategy_binding_value_invalid")?;
620            ensure!(binding.ordinal < 16, "strategy_binding_limit");
621            validate_optional_text(&binding.model, "strategy_binding_model_invalid")?;
622            validate_optional_text(
623                &binding.reasoning_effort,
624                "strategy_binding_reasoning_effort_invalid",
625            )?;
626        }
627        let mut request_source = Vec::from(revision_digest.as_bytes());
628        request_source.push(0);
629        request_source.extend_from_slice(&serde_json::to_vec(&input)?);
630        request_source.push(0);
631        request_source.extend_from_slice(conversation_id.as_bytes());
632        request_source.push(0);
633        request_source.extend_from_slice(assistant_membership_id.as_bytes());
634        request_source.push(0);
635        request_source.extend_from_slice(&serde_json::to_vec(&route_receipt)?);
636        let request_digest = sha256_hex(&request_source);
637        self.with_connection(|connection| {
638            let transaction =
639                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
640            let existing: Option<(String, String)> = transaction
641                .query_row(
642                    "SELECT run_id, request_digest FROM strategy_runs WHERE idempotency_key=?1",
643                    params![idempotency_key],
644                    |row| Ok((row.get(0)?, row.get(1)?)),
645                )
646                .optional()?;
647            if let Some((run_id, existing_digest)) = existing {
648                ensure!(
649                    existing_digest == request_digest,
650                    "strategy_idempotency_conflict"
651                );
652                let snapshot = load_run(&transaction, &run_id)?;
653                transaction.commit()?;
654                return Ok((snapshot, false));
655            }
656
657            transaction.execute(
658                "INSERT INTO strategy_definitions(
659                   definition_id, revision_digest, semantics_digest, name, version,
660                   workflow_json, asset_count, imported_at
661                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7)
662                 ON CONFLICT(revision_digest) DO NOTHING",
663                params![
664                    compiled.definition().metadata.id,
665                    revision_digest,
666                    semantics_digest,
667                    compiled.definition().metadata.name,
668                    compiled.definition().metadata.version,
669                    workflow_json,
670                    now_ms(),
671                ],
672            )?;
673            let stored_identity: (String, String, String) = transaction.query_row(
674                "SELECT definition_id, semantics_digest, workflow_json
675                 FROM strategy_definitions WHERE revision_digest=?1",
676                params![revision_digest],
677                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
678            )?;
679            ensure!(
680                stored_identity
681                    == (
682                        compiled.definition().metadata.id.clone(),
683                        semantics_digest.to_owned(),
684                        serde_json::to_string(compiled.definition())?,
685                    ),
686                "strategy_revision_conflict"
687            );
688            for binding in &expected_bindings {
689                transaction.execute(
690                    "INSERT OR IGNORE INTO strategy_bindings(
691                       revision_digest, slot_id, ordinal, value_id, model,
692                       reasoning_effort, revision
693                     ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
694                    params![
695                        revision_digest,
696                        binding.slot_id,
697                        binding.ordinal as i64,
698                        binding.value_id,
699                        binding.model,
700                        binding.reasoning_effort,
701                    ],
702                )?;
703            }
704            let definition = definition_by_revision(&transaction, revision_digest)?;
705            let mut stored_bindings = definition.bindings.clone();
706            stored_bindings.sort_by(|left, right| {
707                left.slot_id
708                    .cmp(&right.slot_id)
709                    .then_with(|| left.ordinal.cmp(&right.ordinal))
710            });
711            ensure!(
712                stored_bindings.len() == expected_bindings.len()
713                    && stored_bindings
714                        .iter()
715                        .zip(&expected_bindings)
716                        .all(|(stored, expected)| {
717                            stored.slot_id == expected.slot_id
718                                && stored.ordinal == expected.ordinal
719                                && stored.value_id == expected.value_id
720                                && stored.model == expected.model
721                                && stored.reasoning_effort == expected.reasoning_effort
722                        })
723                    && bindings_complete(&definition),
724                "strategy_revision_conflict"
725            );
726            let frozen_binding_digest = binding_digest(&stored_bindings)?;
727            let frozen_authorization_digest =
728                authorization_digest(revision_digest, semantics_digest, &frozen_binding_digest, 1);
729            transaction.execute(
730                "INSERT OR IGNORE INTO strategy_authorizations(
731                   revision_digest, revision, semantics_digest, binding_digest,
732                   authorization_digest, active, created_at
733                 ) VALUES (?1, 1, ?2, ?3, ?4, 1, ?5)",
734                params![
735                    revision_digest,
736                    semantics_digest,
737                    frozen_binding_digest,
738                    frozen_authorization_digest,
739                    now_ms(),
740                ],
741            )?;
742            let authorization = load_authorization(&transaction, revision_digest)?
743                .ok_or_else(|| anyhow!("strategy_authorization_stale"))?;
744            ensure!(
745                authorization.active
746                    && authorization.revision == 1
747                    && authorization.semantics_digest == semantics_digest
748                    && authorization.binding_digest == frozen_binding_digest
749                    && authorization.authorization_digest == frozen_authorization_digest,
750                "strategy_authorization_stale"
751            );
752
753            let slot_candidate_counts = slot_candidate_counts(&stored_bindings);
754            let run_id = format!("run-{}", Uuid::new_v4());
755            let empty = RunSnapshot::empty(&run_id, revision_digest, semantics_digest);
756            let event = ReducerEvent::Start { input };
757            let output = reduce(&compiled, &empty, event.clone())?;
758            let mut snapshot = output.snapshot;
759            snapshot.conversation_id = Some(conversation_id.to_owned());
760            snapshot.assistant_membership_id = Some(assistant_membership_id.to_owned());
761            snapshot.route_receipt = Some(route_receipt);
762            snapshot.slot_candidate_counts = slot_candidate_counts;
763            let now = now_ms();
764            transaction.execute(
765                "INSERT INTO strategy_runs(
766                   run_id, revision_digest, semantics_digest, idempotency_key,
767                   request_digest, snapshot_json, conversation_id, terminal,
768                   created_at, updated_at
769                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
770                params![
771                    run_id,
772                    revision_digest,
773                    semantics_digest,
774                    idempotency_key,
775                    request_digest,
776                    serde_json::to_string(&snapshot)?,
777                    conversation_id,
778                    i64::from(run_is_terminal(snapshot.status)),
779                    now,
780                ],
781            )?;
782            persist_event_and_commands(
783                &transaction,
784                &empty,
785                &snapshot,
786                &event,
787                &output.emitted_commands,
788                now,
789            )?;
790            transaction.commit()?;
791            Ok((snapshot, true))
792        })
793    }
794
795    pub fn run_id_by_idempotency_key(&self, idempotency_key: &str) -> Result<Option<String>> {
796        validate_opaque_id(idempotency_key, "strategy_idempotency_key_invalid")?;
797        self.with_connection(|connection| {
798            connection
799                .query_row(
800                    "SELECT run_id FROM strategy_runs WHERE idempotency_key=?1",
801                    params![idempotency_key],
802                    |row| row.get(0),
803                )
804                .optional()
805                .map_err(Into::into)
806        })
807    }
808
809    pub fn run(&self, run_id: &str) -> Result<RunSnapshot> {
810        self.with_connection(|connection| load_run(connection, run_id))
811    }
812
813    pub fn apply_event(&self, run_id: &str, event: ReducerEvent) -> Result<RunSnapshot> {
814        let committed = self.apply_event_with_commit(run_id, event)?;
815        match committed {
816            Some(committed) => Ok(committed.after),
817            None => self.run(run_id),
818        }
819    }
820
821    pub(crate) fn apply_event_with_commit(
822        &self,
823        run_id: &str,
824        event: ReducerEvent,
825    ) -> Result<Option<super::CommittedTransition>> {
826        self.with_connection(|connection| {
827            let transaction =
828                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
829            let previous = load_run(&transaction, run_id)?;
830            let workflow = workflow_for_revision(&transaction, &previous.definition_digest)?;
831            let compiled = compile_workflow(workflow)?;
832            let output = reduce(&compiled, &previous, event.clone())?;
833            if output.applied {
834                let now = now_ms();
835                persist_event_and_commands(
836                    &transaction,
837                    &previous,
838                    &output.snapshot,
839                    &event,
840                    &output.emitted_commands,
841                    now,
842                )?;
843                transaction.execute(
844                    "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3,
845                     terminal=?4, updated_at=?5 WHERE run_id=?1",
846                    params![
847                        run_id,
848                        serde_json::to_string(&output.snapshot)?,
849                        output.snapshot.conversation_id,
850                        i64::from(run_is_terminal(output.snapshot.status)),
851                        now
852                    ],
853                )?;
854            }
855            transaction.commit()?;
856            if output.applied {
857                Ok(Some(super::CommittedTransition {
858                    run_id: run_id.to_owned(),
859                    sequence: output.snapshot.sequence,
860                    event,
861                    before: previous,
862                    after: output.snapshot,
863                    created_at_unix_ms: now_ms(),
864                }))
865            } else {
866                Ok(None)
867            }
868        })
869    }
870
871    pub fn claim_next_command(
872        &self,
873        run_id: &str,
874        claimant: &str,
875        lease_until_unix_ms: i64,
876    ) -> Result<Option<RunCommand>> {
877        validate_opaque_id(claimant, "strategy_claimant_invalid")?;
878        let now = now_ms();
879        ensure!(lease_until_unix_ms > now, "strategy_lease_invalid");
880        self.with_connection(|connection| {
881            let transaction =
882                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
883            let active: i64 = transaction.query_row(
884                "SELECT COUNT(*) FROM strategy_commands
885                 WHERE status IN ('claimed', 'running') AND lease_until>?1",
886                params![now],
887                |row| row.get(0),
888            )?;
889            if active >= licoup_workflow::MAX_ACTIVE_EFFECTS as i64 {
890                transaction.commit()?;
891                return Ok(None);
892            }
893            let previous = load_run(&transaction, run_id)?;
894            if !matches!(
895                previous.status,
896                StrategyRunStatus::Running
897                    | StrategyRunStatus::Waiting
898                    | StrategyRunStatus::Retryable
899            ) {
900                transaction.commit()?;
901                return Ok(None);
902            }
903            let workflow = workflow_for_revision(&transaction, &previous.definition_digest)?;
904            let compiled = compile_workflow(workflow)?;
905            let run_active: i64 = transaction.query_row(
906                "SELECT COUNT(*) FROM strategy_commands
907                 WHERE run_id=?1 AND status IN ('claimed', 'running') AND lease_until>?2",
908                params![run_id, now],
909                |row| row.get(0),
910            )?;
911            if run_active >= compiled.definition().limits.max_parallelism as i64 {
912                transaction.commit()?;
913                return Ok(None);
914            }
915            let value: Option<String> = transaction
916                .query_row(
917                    "SELECT command_json FROM strategy_commands
918                     WHERE run_id=?1 AND status='pending' AND kind!='authorization'
919                     ORDER BY command_id ASC LIMIT 1",
920                    params![run_id],
921                    |row| row.get(0),
922                )
923                .optional()?;
924            let Some(value) = value else {
925                transaction.commit()?;
926                return Ok(None);
927            };
928            let command: RunCommand = serde_json::from_str(&value)?;
929            let event = ReducerEvent::CommandClaimed {
930                command_id: command.id.clone(),
931                attempt_token: command.attempt_token.clone(),
932            };
933            let output = reduce(&compiled, &previous, event.clone())?;
934            ensure!(output.applied, "strategy_command_not_claimable");
935            persist_event_and_commands(
936                &transaction,
937                &previous,
938                &output.snapshot,
939                &event,
940                &output.emitted_commands,
941                now,
942            )?;
943            transaction.execute(
944                "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3,
945                 terminal=?4, updated_at=?5 WHERE run_id=?1",
946                params![
947                    run_id,
948                    serde_json::to_string(&output.snapshot)?,
949                    output.snapshot.conversation_id,
950                    i64::from(run_is_terminal(output.snapshot.status)),
951                    now
952                ],
953            )?;
954            transaction.execute(
955                "UPDATE strategy_commands SET status='claimed', lease_owner=?2,
956                 lease_until=?3, command_json=?4, updated_at=?5 WHERE command_id=?1",
957                params![
958                    command.id,
959                    claimant,
960                    lease_until_unix_ms,
961                    serde_json::to_string(&output.snapshot.commands[&command.id])?,
962                    now,
963                ],
964            )?;
965            let claimed = output.snapshot.commands.get(&command.id).cloned();
966            transaction.commit()?;
967            Ok(claimed)
968        })
969    }
970
971    pub fn renew_command_lease(
972        &self,
973        command_id: &str,
974        claimant: &str,
975        lease_until_unix_ms: i64,
976    ) -> Result<()> {
977        validate_opaque_id(claimant, "strategy_claimant_invalid")?;
978        ensure!(lease_until_unix_ms > now_ms(), "strategy_lease_invalid");
979        self.with_connection(|connection| {
980            let changed = connection.execute(
981                "UPDATE strategy_commands SET lease_until=?3, updated_at=?4
982                 WHERE command_id=?1 AND lease_owner=?2
983                   AND status IN ('claimed', 'running')",
984                params![command_id, claimant, lease_until_unix_ms, now_ms()],
985            )?;
986            ensure!(changed == 1, "strategy_lease_lost");
987            Ok(())
988        })
989    }
990
991    /// Revalidate the exact command and authorization immediately before an
992    /// effect permit is issued. The write lock serializes this admission with
993    /// binding updates and authorization revocation; a later revoke does not
994    /// retroactively invalidate the already-issued one-shot permit.
995    pub fn authorize_effect(
996        &self,
997        run_id: &str,
998        command_id: &str,
999        attempt_token: &str,
1000        expected_authorization_digest: &str,
1001        claimant: &str,
1002        lease_until_unix_ms: i64,
1003    ) -> Result<()> {
1004        validate_opaque_id(claimant, "strategy_claimant_invalid")?;
1005        let now = now_ms();
1006        ensure!(lease_until_unix_ms > now, "strategy_lease_invalid");
1007        self.with_connection(|connection| {
1008            let transaction =
1009                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1010            let lease: Option<(String, String, Option<String>, Option<i64>)> = transaction
1011                .query_row(
1012                    "SELECT status, attempt_token, lease_owner, lease_until
1013                     FROM strategy_commands WHERE command_id=?1 AND run_id=?2",
1014                    params![command_id, run_id],
1015                    |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1016                )
1017                .optional()?;
1018            ensure!(
1019                lease.is_some_and(|(status, token, owner, lease_until)| {
1020                    status == "running"
1021                        && token == attempt_token
1022                        && owner.as_deref() == Some(claimant)
1023                        && lease_until.is_some_and(|value| value > now)
1024                }),
1025                "strategy_lease_lost"
1026            );
1027            let snapshot = load_run(&transaction, run_id)?;
1028            let command = snapshot
1029                .commands
1030                .get(command_id)
1031                .filter(|command| {
1032                    command.status == CommandStatus::Running
1033                        && command.attempt_token == attempt_token
1034                })
1035                .ok_or_else(|| anyhow!("strategy_callback_stale"))?;
1036            ensure!(command.id == command_id, "strategy_callback_stale");
1037            let definition = definition_by_revision(&transaction, &snapshot.definition_digest)?;
1038            ensure!(
1039                definition.summary.semantics_digest == snapshot.semantics_digest,
1040                "strategy_authorization_stale"
1041            );
1042            ensure!(
1043                bindings_complete(&definition),
1044                "strategy_binding_incomplete"
1045            );
1046            let authorization = current_authorization(&definition)?;
1047            ensure!(
1048                authorization.binding_digest == binding_digest(&definition.bindings)?
1049                    && authorization.authorization_digest == expected_authorization_digest,
1050                "strategy_authorization_stale"
1051            );
1052            let renewed = transaction.execute(
1053                "UPDATE strategy_commands SET lease_until=?4, updated_at=?5
1054                 WHERE command_id=?1 AND run_id=?2 AND lease_owner=?3
1055                   AND status='running' AND lease_until>?5",
1056                params![command_id, run_id, claimant, lease_until_unix_ms, now],
1057            )?;
1058            ensure!(renewed == 1, "strategy_lease_lost");
1059            transaction.commit()?;
1060            Ok(())
1061        })
1062    }
1063
1064    /// Atomically fence and recover the next expired effect claim of one run.
1065    ///
1066    /// Lease renewal and this recovery both require the same SQLite write
1067    /// lock. The winner observes and commits one state transition; the loser
1068    /// cannot act on a stale pre-lock observation. Claimed-before-start work
1069    /// is retried in the same transaction, while expired running work is
1070    /// retained as in-doubt and is never blindly retried.
1071    ///
1072    /// A previous host process disappearing is **not** an input here. There is
1073    /// no abandoned-host path: an unexpired claim stays held by its recorded
1074    /// owner, so process exit is neither lease revocation nor authority to
1075    /// repeat an external effect. Only the persisted lease clock resolves a
1076    /// claim, and only in one of two ways: a claim whose effect never started is
1077    /// retried against the same attempt identity, while a claim whose effect was
1078    /// already in flight stays in doubt and is never blindly repeated.
1079    pub fn recover_next_expired_command(&self, run_id: &str) -> Result<bool> {
1080        let Some(command_id) = self.next_expired_leased_command_id(run_id)? else {
1081            return Ok(false);
1082        };
1083        self.recover_leased_command(run_id, &command_id)?;
1084        Ok(true)
1085    }
1086
1087    fn next_expired_leased_command_id(&self, run_id: &str) -> Result<Option<String>> {
1088        self.with_connection(|connection| {
1089            connection
1090                .query_row(
1091                    "SELECT command_id FROM strategy_commands
1092                     WHERE run_id=?1 AND status IN ('claimed', 'running')
1093                       AND lease_until IS NOT NULL AND lease_until<=?2
1094                     ORDER BY command_id ASC LIMIT 1",
1095                    params![run_id, now_ms()],
1096                    |row| row.get(0),
1097                )
1098                .optional()
1099                .map_err(Into::into)
1100        })
1101    }
1102
1103    fn recover_leased_command(&self, run_id: &str, command_id: &str) -> Result<()> {
1104        self.with_connection(|connection| {
1105            let transaction =
1106                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1107            let value: Option<(String, String)> = transaction
1108                .query_row(
1109                    "SELECT command_json, status FROM strategy_commands
1110                     WHERE run_id=?1 AND command_id=?2 AND status IN ('claimed', 'running')",
1111                    params![run_id, command_id],
1112                    |row| Ok((row.get(0)?, row.get(1)?)),
1113                )
1114                .optional()?;
1115            let Some((command_json, persisted_status)) = value else {
1116                transaction.commit()?;
1117                return Ok(());
1118            };
1119            let command: RunCommand = serde_json::from_str(&command_json)?;
1120            let expected_status = enum_wire(command.status)?;
1121            ensure!(
1122                expected_status == persisted_status
1123                    && matches!(
1124                        command.status,
1125                        CommandStatus::Claimed | CommandStatus::Running
1126                    ),
1127                "strategy_recovery_state_conflict"
1128            );
1129            let previous = load_run(&transaction, run_id)?;
1130            ensure!(
1131                previous.commands.get(&command.id).is_some_and(|current| {
1132                    current.status == command.status
1133                        && current.attempt_token == command.attempt_token
1134                }),
1135                "strategy_recovery_state_conflict"
1136            );
1137            let workflow = workflow_for_revision(&transaction, &previous.definition_digest)?;
1138            let compiled = compile_workflow(workflow)?;
1139            let (class, code) = match command.status {
1140                CommandStatus::Claimed => (FailureClass::Transient, "lease_expired_before_start"),
1141                _ => (FailureClass::InDoubt, "effect_outcome_unknown"),
1142            };
1143            let failure_event = ReducerEvent::CommandFailed {
1144                command_id: command.id.clone(),
1145                attempt_token: command.attempt_token.clone(),
1146                class,
1147                code: code.to_owned(),
1148            };
1149            let failure = reduce(&compiled, &previous, failure_event.clone())?;
1150            let now = now_ms();
1151            persist_event_and_commands(
1152                &transaction,
1153                &previous,
1154                &failure.snapshot,
1155                &failure_event,
1156                &failure.emitted_commands,
1157                now,
1158            )?;
1159            let final_snapshot = if failure
1160                .snapshot
1161                .commands
1162                .get(&command.id)
1163                .is_some_and(|current| current.status == CommandStatus::Retryable)
1164            {
1165                let retry_event = ReducerEvent::RetryRequested {
1166                    command_id: command.id.clone(),
1167                };
1168                let retry = reduce(&compiled, &failure.snapshot, retry_event.clone())?;
1169                persist_event_and_commands(
1170                    &transaction,
1171                    &failure.snapshot,
1172                    &retry.snapshot,
1173                    &retry_event,
1174                    &retry.emitted_commands,
1175                    now,
1176                )?;
1177                retry.snapshot
1178            } else {
1179                failure.snapshot
1180            };
1181            transaction.execute(
1182                "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3,
1183                 terminal=?4, updated_at=?5 WHERE run_id=?1",
1184                params![
1185                    run_id,
1186                    serde_json::to_string(&final_snapshot)?,
1187                    final_snapshot.conversation_id,
1188                    i64::from(run_is_terminal(final_snapshot.status)),
1189                    now
1190                ],
1191            )?;
1192            transaction.execute(
1193                "UPDATE strategy_commands SET lease_owner=NULL, lease_until=NULL
1194                 WHERE command_id=?1",
1195                params![&command.id],
1196            )?;
1197            transaction.commit()?;
1198            Ok(())
1199        })
1200    }
1201
1202    pub fn projection_for_definition(&self, revision_digest: &str) -> Result<StrategyProjection> {
1203        let definition = self.definition_by_revision(revision_digest)?;
1204        let complete = bindings_complete(&definition);
1205        let mut allowed = BTreeSet::from([
1206            "strategy.definition.inspect".into(),
1207            "strategy.binding.update".into(),
1208            "strategy.binding.replace".into(),
1209            "strategy.authorization.preview".into(),
1210        ]);
1211        let status = if complete {
1212            if definition
1213                .authorization
1214                .as_ref()
1215                .is_some_and(|value| value.active)
1216            {
1217                allowed.extend([
1218                    "strategy.authorization.revoke".into(),
1219                    "strategy.run.start".into(),
1220                ]);
1221                StrategyRunStatus::Pending
1222            } else {
1223                allowed.insert("strategy.authorization.grant".into());
1224                StrategyRunStatus::AuthorizationRequired
1225            }
1226        } else {
1227            StrategyRunStatus::Pending
1228        };
1229        Ok(StrategyProjection {
1230            schema: STRATEGY_SCHEMA_VERSION.into(),
1231            definition: definition.summary,
1232            run_id: None,
1233            status,
1234            current_states: BTreeSet::new(),
1235            neighbor_states: BTreeSet::new(),
1236            allowed_operations: allowed,
1237            bindings: definition.bindings,
1238            diagnostic: if complete {
1239                None
1240            } else {
1241                Some(StrategyDiagnostic {
1242                    code: "binding_incomplete".into(),
1243                    component: "strategy_binding".into(),
1244                    retryable: true,
1245                    recovery: "Bind every required actor and runtime slot.".into(),
1246                    arguments: BTreeMap::new(),
1247                })
1248            },
1249            history_count: 0,
1250            fallbacks: Vec::new(),
1251            pending_callbacks: Vec::new(),
1252            needs_human_input: false,
1253            entry_session_id: None,
1254        })
1255    }
1256
1257    pub fn projection_for_run(&self, run_id: &str) -> Result<StrategyProjection> {
1258        let snapshot = self.run(run_id)?;
1259        let definition = self.definition_by_revision(&snapshot.definition_digest)?;
1260        let compiled = compile_workflow(definition.workflow.clone())?;
1261        let neighbors = snapshot
1262            .active_states
1263            .iter()
1264            .flat_map(|state| {
1265                compiled
1266                    .outgoing(state)
1267                    .map(|transition| transition.to.clone())
1268            })
1269            .collect();
1270        let mut allowed = BTreeSet::from(["strategy.run.inspect".into()]);
1271        match snapshot.status {
1272            StrategyRunStatus::AuthorizationRequired
1273            | StrategyRunStatus::RuntimeMissing
1274            | StrategyRunStatus::Waiting => {
1275                allowed.insert("strategy.run.resume".into());
1276                allowed.insert("strategy.run.cancel".into());
1277            }
1278            StrategyRunStatus::Running => {
1279                allowed.insert("strategy.run.resume".into());
1280                allowed.insert("strategy.run.cancel".into());
1281            }
1282            StrategyRunStatus::Retryable => {
1283                allowed.insert("strategy.run.retry".into());
1284                allowed.insert("strategy.run.cancel".into());
1285            }
1286            StrategyRunStatus::CancelRequested => {
1287                allowed.insert("strategy.run.inspect".into());
1288            }
1289            _ => {}
1290        }
1291        let history_count = self.with_connection(|connection| -> Result<u64> {
1292            connection
1293                .query_row(
1294                    "SELECT COUNT(*) FROM strategy_run_events WHERE run_id=?1",
1295                    params![run_id],
1296                    |row| row.get::<_, i64>(0).map(|value| value as u64),
1297                )
1298                .map_err(Into::into)
1299        })?;
1300        let pending_callbacks = snapshot.pending_callbacks.clone();
1301        Ok(StrategyProjection {
1302            schema: STRATEGY_SCHEMA_VERSION.into(),
1303            definition: definition.summary,
1304            run_id: Some(run_id.to_owned()),
1305            status: snapshot.status,
1306            current_states: snapshot.active_states,
1307            neighbor_states: neighbors,
1308            allowed_operations: allowed,
1309            bindings: definition.bindings,
1310            diagnostic: snapshot.diagnostic_code.map(|code| StrategyDiagnostic {
1311                component: "strategy_runtime".into(),
1312                retryable: matches!(
1313                    snapshot.status,
1314                    StrategyRunStatus::Retryable
1315                        | StrategyRunStatus::RuntimeMissing
1316                        | StrategyRunStatus::AuthorizationRequired
1317                ),
1318                recovery: recovery_for_status(snapshot.status).into(),
1319                code,
1320                arguments: BTreeMap::new(),
1321            }),
1322            history_count,
1323            fallbacks: snapshot.fallbacks,
1324            pending_callbacks: pending_callbacks.clone(),
1325            needs_human_input: !pending_callbacks.is_empty()
1326                || matches!(
1327                    snapshot.status,
1328                    StrategyRunStatus::AuthorizationRequired
1329                        | StrategyRunStatus::RuntimeMissing
1330                        | StrategyRunStatus::Waiting
1331                        | StrategyRunStatus::Retryable
1332                ),
1333            entry_session_id: definition
1334                .workflow
1335                .actor_slots
1336                .iter()
1337                .find(|slot| slot.kind == licoup_workflow::BindingKind::Actor && slot.entry)
1338                .and_then(|slot| {
1339                    let prefix = format!("{}\0", slot.id);
1340                    snapshot
1341                        .actor_sessions
1342                        .iter()
1343                        .filter(|(key, _)| key.starts_with(&prefix))
1344                        .filter_map(|(key, session)| {
1345                            snapshot
1346                                .merge_sources
1347                                .get(&format!("session\0{key}"))
1348                                .map(|source| (source, key, session))
1349                        })
1350                        .max_by(|left, right| left.0.cmp(right.0).then(left.1.cmp(right.1)))
1351                        .map(|(_, _, session)| session.clone())
1352                }),
1353        })
1354    }
1355
1356    pub fn active_run_for_conversation(
1357        &self,
1358        revision_digest: &str,
1359        conversation_id: &str,
1360    ) -> Result<Option<RunSnapshot>> {
1361        validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
1362        self.with_connection(|connection| {
1363            connection
1364                .query_row(
1365                    "SELECT snapshot_json FROM strategy_runs
1366                     WHERE revision_digest=?1 AND conversation_id=?2 AND terminal=0
1367                     ORDER BY updated_at DESC LIMIT 1",
1368                    params![revision_digest, conversation_id],
1369                    |row| row.get::<_, String>(0),
1370                )
1371                .optional()?
1372                .map(|snapshot_json| serde_json::from_str(&snapshot_json).map_err(Into::into))
1373                .transpose()
1374        })
1375    }
1376
1377    pub fn bind_conversation_if_absent(&self, run_id: &str, conversation_id: &str) -> Result<()> {
1378        validate_opaque_id(conversation_id, "strategy_conversation_id_invalid")?;
1379        self.with_connection(|connection| {
1380            let transaction =
1381                connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1382            let mut snapshot = load_run(&transaction, run_id)?;
1383            if snapshot
1384                .conversation_id
1385                .as_deref()
1386                .is_some_and(|value| !value.is_empty())
1387            {
1388                transaction.commit()?;
1389                return Ok(());
1390            }
1391            snapshot.conversation_id = Some(conversation_id.to_owned());
1392            transaction.execute(
1393                "UPDATE strategy_runs SET snapshot_json=?2, conversation_id=?3, updated_at=?4
1394                 WHERE run_id=?1",
1395                params![
1396                    run_id,
1397                    serde_json::to_string(&snapshot)?,
1398                    snapshot.conversation_id,
1399                    now_ms()
1400                ],
1401            )?;
1402            transaction.commit()?;
1403            Ok(())
1404        })
1405    }
1406
1407    /// Remove the on-disk package trees of definitions the typed migration
1408    /// deleted. Only runs for digests the migration actually returned.
1409    fn remove_retired_revision_trees(&self, digests: &[String]) {
1410        for digest in digests {
1411            let Some(root) = &self.package_revisions_root else {
1412                return;
1413            };
1414            if digest.len() != 64
1415                || !digest
1416                    .chars()
1417                    .all(|character| character.is_ascii_hexdigit())
1418            {
1419                continue;
1420            }
1421            let path = root.join(digest);
1422            let Ok(metadata) = fs::symlink_metadata(&path) else {
1423                continue;
1424            };
1425            if metadata.file_type().is_symlink() {
1426                continue;
1427            }
1428            remove_directory_tree(&path);
1429        }
1430    }
1431}
1432
1433fn initialize_schema(connection: &mut Connection) -> Result<Vec<String>> {
1434    let retired = initialize_schema_through(connection)?;
1435    let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1436    migrate_legacy_workflow_definitions(&transaction)?;
1437    transaction.execute("UPDATE strategy_meta SET value='3' WHERE key='version'", [])?;
1438    transaction.commit()?;
1439    super::queue::initialize_schema(connection)?;
1440    super::subscriptions::initialize_schema(connection)?;
1441    super::commit::initialize_schema(connection)?;
1442    super::controlled::initialize_schema(connection)?;
1443    Ok(retired)
1444}
1445
1446fn initialize_schema_through(connection: &mut Connection) -> Result<Vec<String>> {
1447    connection.execute_batch(
1448        "CREATE TABLE IF NOT EXISTS strategy_meta(
1449           key TEXT PRIMARY KEY, value TEXT NOT NULL
1450         );
1451         INSERT INTO strategy_meta(key, value) VALUES ('version', '3')
1452           ON CONFLICT(key) DO NOTHING;
1453         CREATE TABLE IF NOT EXISTS strategy_definitions(
1454           definition_id TEXT NOT NULL,
1455           revision_digest TEXT PRIMARY KEY,
1456           semantics_digest TEXT NOT NULL,
1457           name TEXT NOT NULL,
1458           version TEXT NOT NULL,
1459           workflow_json TEXT NOT NULL,
1460           asset_count INTEGER NOT NULL,
1461           imported_at INTEGER NOT NULL
1462         );
1463         CREATE INDEX IF NOT EXISTS strategy_definitions_id_idx
1464           ON strategy_definitions(definition_id, imported_at DESC);
1465         CREATE TABLE IF NOT EXISTS strategy_bindings(
1466           revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
1467           slot_id TEXT NOT NULL,
1468           ordinal INTEGER NOT NULL DEFAULT 0,
1469           value_id TEXT NOT NULL,
1470           model TEXT NOT NULL DEFAULT '',
1471           reasoning_effort TEXT NOT NULL DEFAULT '',
1472           revision INTEGER NOT NULL,
1473           PRIMARY KEY(revision_digest, slot_id, ordinal)
1474         );
1475         CREATE TABLE IF NOT EXISTS strategy_authorizations(
1476           revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
1477           revision INTEGER NOT NULL,
1478           semantics_digest TEXT NOT NULL,
1479           binding_digest TEXT NOT NULL,
1480           authorization_digest TEXT NOT NULL,
1481           active INTEGER NOT NULL,
1482           created_at INTEGER NOT NULL,
1483           PRIMARY KEY(revision_digest, revision)
1484         );
1485         CREATE UNIQUE INDEX IF NOT EXISTS strategy_authorization_active_idx
1486           ON strategy_authorizations(revision_digest) WHERE active=1;
1487         CREATE TABLE IF NOT EXISTS strategy_runs(
1488           run_id TEXT PRIMARY KEY,
1489           revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest),
1490           semantics_digest TEXT NOT NULL,
1491           idempotency_key TEXT NOT NULL UNIQUE,
1492           request_digest TEXT NOT NULL,
1493           snapshot_json TEXT NOT NULL,
1494           conversation_id TEXT,
1495           terminal INTEGER NOT NULL,
1496           created_at INTEGER NOT NULL,
1497           updated_at INTEGER NOT NULL
1498         );
1499         CREATE INDEX IF NOT EXISTS strategy_runs_revision_idx
1500           ON strategy_runs(revision_digest, updated_at DESC);
1501         CREATE TABLE IF NOT EXISTS strategy_run_events(
1502           run_id TEXT NOT NULL REFERENCES strategy_runs(run_id) ON DELETE CASCADE,
1503           sequence INTEGER NOT NULL,
1504           event_type TEXT NOT NULL,
1505           event_json TEXT NOT NULL,
1506           created_at INTEGER NOT NULL,
1507           PRIMARY KEY(run_id, sequence)
1508         );
1509         CREATE TABLE IF NOT EXISTS strategy_commands(
1510           command_id TEXT PRIMARY KEY,
1511           run_id TEXT NOT NULL REFERENCES strategy_runs(run_id) ON DELETE CASCADE,
1512           state_id TEXT NOT NULL,
1513           kind TEXT NOT NULL,
1514           status TEXT NOT NULL,
1515           attempt INTEGER NOT NULL,
1516           attempt_token TEXT NOT NULL,
1517           command_json TEXT NOT NULL,
1518           lease_owner TEXT,
1519           lease_until INTEGER,
1520           updated_at INTEGER NOT NULL
1521         );
1522         CREATE INDEX IF NOT EXISTS strategy_commands_ready_idx
1523           ON strategy_commands(status, command_id);
1524         CREATE INDEX IF NOT EXISTS strategy_commands_lease_idx
1525           ON strategy_commands(lease_until) WHERE status IN ('claimed', 'running');",
1526    )?;
1527    ensure_column(
1528        connection,
1529        "strategy_bindings",
1530        "model",
1531        "TEXT NOT NULL DEFAULT ''",
1532    )?;
1533    ensure_column(
1534        connection,
1535        "strategy_bindings",
1536        "reasoning_effort",
1537        "TEXT NOT NULL DEFAULT ''",
1538    )?;
1539    ensure_column(connection, "strategy_runs", "conversation_id", "TEXT")?;
1540    ensure_column(connection, "strategy_runs", "terminal", "INTEGER")?;
1541    let retired = migrate_retired_builtin_definition(connection)?;
1542    backfill_run_query_columns(connection)?;
1543    connection.execute_batch(
1544        "CREATE INDEX IF NOT EXISTS strategy_runs_active_conversation_idx
1545           ON strategy_runs(revision_digest, conversation_id, terminal, updated_at DESC);",
1546    )?;
1547    migrate_bindings_ordinal_primary_key(connection)?;
1548    Ok(retired)
1549}
1550
1551/// Typed one-time migration for the retired built-in strategy: databases from
1552/// before the retirement still hold its definition rows (and runs), and the
1553/// removal lives here — at migration, with no runtime compatibility path.
1554/// Returns the deleted revision digests so the caller can drop the orphaned
1555/// package trees.
1556fn migrate_retired_builtin_definition(connection: &mut Connection) -> Result<Vec<String>> {
1557    const RETIRED_DEFINITION_ID: &str = "licoup-basic";
1558    const RETIRED_DEFINITION_NAME: &str = "LicoUp Basic Strategy";
1559    let mut statement = connection.prepare(
1560        "SELECT revision_digest FROM strategy_definitions
1561         WHERE definition_id=?1 OR name=?2",
1562    )?;
1563    let digests = statement
1564        .query_map(
1565            params![RETIRED_DEFINITION_ID, RETIRED_DEFINITION_NAME],
1566            |row| row.get::<_, String>(0),
1567        )?
1568        .collect::<rusqlite::Result<Vec<_>>>()?;
1569    drop(statement);
1570    for digest in &digests {
1571        connection.execute(
1572            "DELETE FROM strategy_runs WHERE revision_digest=?1",
1573            params![digest],
1574        )?;
1575        connection.execute(
1576            "DELETE FROM strategy_definitions WHERE revision_digest=?1",
1577            params![digest],
1578        )?;
1579    }
1580    Ok(digests)
1581}
1582
1583fn validate_current_schema(connection: &mut Connection) -> Result<()> {
1584    configure_connection(connection)?;
1585    let version: String = connection.query_row(
1586        "SELECT value FROM strategy_meta WHERE key='version'",
1587        [],
1588        |row| row.get(0),
1589    )?;
1590    ensure!(version == "3", "strategy_schema_migration_required");
1591    super::queue::initialize_schema(connection)?;
1592    super::subscriptions::initialize_schema(connection)?;
1593    super::commit::initialize_schema(connection)?;
1594    super::controlled::initialize_schema(connection)?;
1595    Ok(())
1596}
1597
1598pub(crate) fn preflight_existing_store(path: &Path) -> Result<()> {
1599    let metadata = match fs::symlink_metadata(path) {
1600        Ok(metadata) => metadata,
1601        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1602        Err(error) => return Err(error.into()),
1603    };
1604    ensure!(
1605        metadata.is_file() && !metadata.file_type().is_symlink(),
1606        "unsupported_state_shape"
1607    );
1608    let connection = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
1609    let version: String = connection.query_row(
1610        "SELECT value FROM strategy_meta WHERE key='version'",
1611        [],
1612        |row| row.get(0),
1613    )?;
1614    ensure!(
1615        matches!(version.as_str(), "2" | "3"),
1616        "unsupported_state_shape"
1617    );
1618    ensure!(version == "3", "strategy_schema_migration_required");
1619    validate_published_core_layout(&connection, &version)
1620}
1621
1622/// Read-only validation of the published core layout.
1623///
1624/// `expected_meta_version` is the `strategy_meta.version` the caller classified
1625/// the file as (`2` for the released layout, `3` for this binary's current
1626/// one). The tables, columns, keys, foreign keys, uniqueness constraints and
1627/// named indexes are then checked exactly, so a database that merely carries a
1628/// version row — or a seven-table database whose ordinal primary key or unique
1629/// authorization index is missing — is refused without writing a byte.
1630pub fn validate_published_core_layout(
1631    connection: &Connection,
1632    expected_meta_version: &str,
1633) -> Result<()> {
1634    let version: Option<String> = connection
1635        .query_row(
1636            "SELECT value FROM strategy_meta WHERE key='version'",
1637            [],
1638            |row| row.get(0),
1639        )
1640        .optional()?;
1641    ensure!(
1642        version.as_deref() == Some(expected_meta_version),
1643        "unsupported_state_shape"
1644    );
1645    // Validate all retained write semantics, including tables created on demand
1646    // by the current owner. An absent auxiliary table is created by startup; a
1647    // malformed existing table is never repaired by CREATE IF NOT EXISTS.
1648    let mut reference = Connection::open_in_memory()?;
1649    initialize_schema_through(&mut reference)?;
1650    let required = licoup_foundation::core::sqlite_contract::tables(&reference)?;
1651    super::queue::initialize_schema(&reference)?;
1652    super::subscriptions::initialize_schema(&reference)?;
1653    super::commit::initialize_schema(&reference)?;
1654    super::controlled::initialize_schema(&reference)?;
1655    let existing = licoup_foundation::core::sqlite_contract::tables(connection)?;
1656    for table in licoup_foundation::core::sqlite_contract::tables(&reference)? {
1657        if !existing.contains(&table) && !required.contains(&table) {
1658            continue;
1659        }
1660        licoup_foundation::core::sqlite_contract::validate_table(
1661            connection,
1662            &reference,
1663            &table,
1664            match table.as_str() {
1665                "strategy_runs" => Some(ColumnVariant::Nullable("terminal")),
1666                // v0.2.1's ordinal-key conversion omitted the fresh-store
1667                // DEFAULT 0. Both published producers supply ordinal values.
1668                "strategy_bindings" => Some(ColumnVariant::MissingZeroDefault("ordinal")),
1669                _ => None,
1670            },
1671        )?;
1672    }
1673    Ok(())
1674}
1675
1676fn backfill_run_query_columns(connection: &mut Connection) -> Result<()> {
1677    let mut statement = connection
1678        .prepare("SELECT run_id, snapshot_json FROM strategy_runs WHERE terminal IS NULL")?;
1679    let runs = statement
1680        .query_map([], |row| {
1681            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1682        })?
1683        .collect::<rusqlite::Result<Vec<_>>>()?;
1684    drop(statement);
1685    for (run_id, snapshot_json) in runs {
1686        let snapshot: RunSnapshot = serde_json::from_str(&snapshot_json)?;
1687        connection.execute(
1688            "UPDATE strategy_runs SET conversation_id=?2, terminal=?3 WHERE run_id=?1",
1689            params![
1690                run_id,
1691                snapshot.conversation_id,
1692                i64::from(run_is_terminal(snapshot.status))
1693            ],
1694        )?;
1695    }
1696    Ok(())
1697}
1698
1699fn run_is_terminal(status: StrategyRunStatus) -> bool {
1700    matches!(
1701        status,
1702        StrategyRunStatus::Completed
1703            | StrategyRunStatus::Failed
1704            | StrategyRunStatus::Cancelled
1705            | StrategyRunStatus::Blocked
1706            | StrategyRunStatus::CancelInDoubt
1707    )
1708}
1709
1710fn migrate_bindings_ordinal_primary_key(connection: &mut Connection) -> Result<()> {
1711    let mut statement = connection.prepare("PRAGMA table_info(strategy_bindings)")?;
1712    let columns = statement
1713        .query_map([], |row| row.get::<_, String>(1))?
1714        .collect::<rusqlite::Result<Vec<_>>>()?;
1715    if columns.iter().any(|column| column == "ordinal") {
1716        return Ok(());
1717    }
1718    connection.execute_batch(
1719        "CREATE TABLE strategy_bindings_v2 (
1720           revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
1721           slot_id TEXT NOT NULL,
1722           ordinal INTEGER NOT NULL,
1723           value_id TEXT NOT NULL,
1724           model TEXT NOT NULL DEFAULT '',
1725           reasoning_effort TEXT NOT NULL DEFAULT '',
1726           revision INTEGER NOT NULL,
1727           PRIMARY KEY(revision_digest, slot_id, ordinal)
1728         );
1729         INSERT INTO strategy_bindings_v2(
1730           revision_digest, slot_id, ordinal, value_id, model, reasoning_effort, revision
1731         )
1732         SELECT revision_digest, slot_id, 0, value_id, model, reasoning_effort, revision
1733           FROM strategy_bindings;
1734         DROP TABLE strategy_bindings;
1735         ALTER TABLE strategy_bindings_v2 RENAME TO strategy_bindings;
1736         UPDATE strategy_authorizations SET active=0 WHERE active=1;
1737         UPDATE strategy_meta SET value='2' WHERE key='version';",
1738    )?;
1739    Ok(())
1740}
1741
1742/// Materialize the legacy routing conventions at the typed data-migration
1743/// boundary. Runtime compilation only accepts the resulting canonical graph.
1744fn migrate_legacy_workflow_definitions(connection: &Connection) -> Result<()> {
1745    let mut statement = connection.prepare(
1746        "SELECT revision_digest, workflow_json FROM strategy_definitions ORDER BY revision_digest",
1747    )?;
1748    let definitions = statement
1749        .query_map([], |row| {
1750            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1751        })?
1752        .collect::<rusqlite::Result<Vec<_>>>()?;
1753    drop(statement);
1754
1755    for (revision, workflow_json) in definitions {
1756        let Ok(workflow) = serde_json::from_str::<WorkflowDefinition>(&workflow_json) else {
1757            // Preserve unrelated invalid legacy rows for their owning migration
1758            // or existing typed read failure; this step only materializes the
1759            // workflow conventions it can identify safely.
1760            continue;
1761        };
1762        let Some(workflow) = normalize_legacy_workflow(workflow) else {
1763            continue;
1764        };
1765        let compiled = compile_workflow(workflow)?;
1766        let canonical = serde_json::to_string(compiled.definition())?;
1767        connection.execute(
1768            "UPDATE strategy_definitions
1769                SET workflow_json=?2
1770              WHERE revision_digest=?1",
1771            params![revision, canonical],
1772        )?;
1773    }
1774    Ok(())
1775}
1776
1777/// Rewrite one stored definition into the current canonical shape when it was
1778/// written by an earlier development implementation. The returned definition is
1779/// the identity the current machine reduces; `None` means it already is.
1780pub fn normalize_legacy_workflow(mut workflow: WorkflowDefinition) -> Option<WorkflowDefinition> {
1781    let mut changed = false;
1782    if !workflow
1783        .actor_slots
1784        .iter()
1785        .any(|slot| slot.kind == BindingKind::Actor && slot.entry)
1786        && let Some(entry) = workflow
1787            .actor_slots
1788            .iter_mut()
1789            .find(|slot| slot.kind == BindingKind::Actor)
1790    {
1791        entry.entry = true;
1792        changed = true;
1793    }
1794
1795    let effect_ids = workflow
1796        .states
1797        .iter()
1798        .filter(|state| {
1799            matches!(
1800                state.kind,
1801                GraphStateKind::Authorization
1802                    | GraphStateKind::Actor
1803                    | GraphStateKind::Script
1804                    | GraphStateKind::Workset
1805            )
1806        })
1807        .map(|state| state.id.clone())
1808        .collect::<BTreeSet<_>>();
1809    let mut success = BTreeSet::new();
1810    let mut failure = BTreeSet::new();
1811    for transition in &workflow.transitions {
1812        if effect_ids.contains(&transition.from) {
1813            match transition.event {
1814                TransitionEvent::Success => _ = success.insert(transition.from.clone()),
1815                TransitionEvent::Failure => _ = failure.insert(transition.from.clone()),
1816                TransitionEvent::Complete => {}
1817            }
1818        }
1819    }
1820    for transition in &mut workflow.transitions {
1821        if !effect_ids.contains(&transition.from) || transition.event != TransitionEvent::Complete {
1822            continue;
1823        }
1824        changed = true;
1825        if success.insert(transition.from.clone()) {
1826            transition.event = TransitionEvent::Success;
1827        } else if failure.insert(transition.from.clone()) {
1828            transition.event = TransitionEvent::Failure;
1829        }
1830    }
1831    let before = workflow.transitions.len();
1832    workflow.transitions.retain(|transition| {
1833        !(effect_ids.contains(&transition.from) && transition.event == TransitionEvent::Complete)
1834    });
1835    changed |= workflow.transitions.len() != before;
1836
1837    let missing_failure = effect_ids
1838        .iter()
1839        .filter(|state| success.contains(*state) && !failure.contains(*state))
1840        .cloned()
1841        .collect::<Vec<_>>();
1842    if !missing_failure.is_empty() {
1843        changed = true;
1844        let terminal = workflow
1845            .states
1846            .iter()
1847            .find(|state| matches!(state.kind, GraphStateKind::Blocked | GraphStateKind::Fail))
1848            .map(|state| state.id.clone())
1849            .unwrap_or_else(|| {
1850                let mut id = "blocked".to_owned();
1851                let mut suffix = 0u32;
1852                while workflow.states.iter().any(|state| state.id == id) {
1853                    suffix += 1;
1854                    id = format!("blocked-{suffix}");
1855                }
1856                workflow.states.push(GraphState {
1857                    id: id.clone(),
1858                    kind: GraphStateKind::Blocked,
1859                    label: "Blocked".into(),
1860                    instruction: String::new(),
1861                    binding: None,
1862                    runtime: None,
1863                    entry: None,
1864                    workset: None,
1865                    retry: Default::default(),
1866                });
1867                id
1868            });
1869        let mut transition_ids = workflow
1870            .transitions
1871            .iter()
1872            .map(|transition| transition.id.clone())
1873            .collect::<BTreeSet<_>>();
1874        for state in missing_failure {
1875            let mut id = format!("{state}-legacy-failure");
1876            let mut suffix = 0u32;
1877            while !transition_ids.insert(id.clone()) {
1878                suffix += 1;
1879                id = format!("{state}-legacy-failure-{suffix}");
1880            }
1881            workflow.transitions.push(Transition {
1882                id,
1883                from: state,
1884                to: terminal.clone(),
1885                event: TransitionEvent::Failure,
1886                mode: TransitionMode::Flow,
1887                guard: None,
1888            });
1889        }
1890    }
1891    changed.then_some(workflow)
1892}
1893
1894fn configure_connection(connection: &Connection) -> Result<()> {
1895    connection.execute_batch(
1896        "PRAGMA foreign_keys=ON;
1897         PRAGMA journal_mode=WAL;
1898         PRAGMA synchronous=FULL;
1899         PRAGMA busy_timeout=5000;",
1900    )?;
1901    Ok(())
1902}
1903
1904fn definition_by_revision(connection: &Connection, revision: &str) -> Result<StrategyDefinition> {
1905    let base: Option<(String, String, String, String, String, String, i64, i64)> = connection
1906        .query_row(
1907            "SELECT definition_id, revision_digest, semantics_digest, name, version,
1908             workflow_json, asset_count, imported_at FROM strategy_definitions
1909             WHERE revision_digest=?1",
1910            params![revision],
1911            |row| {
1912                Ok((
1913                    row.get(0)?,
1914                    row.get(1)?,
1915                    row.get(2)?,
1916                    row.get(3)?,
1917                    row.get(4)?,
1918                    row.get(5)?,
1919                    row.get(6)?,
1920                    row.get(7)?,
1921                ))
1922            },
1923        )
1924        .optional()?;
1925    let Some((
1926        definition_id,
1927        revision_digest,
1928        semantics_digest,
1929        name,
1930        version,
1931        workflow_json,
1932        asset_count,
1933        imported_at,
1934    )) = base
1935    else {
1936        return Err(anyhow!("strategy_definition_not_found"));
1937    };
1938    let workflow: WorkflowDefinition = serde_json::from_str(&workflow_json)?;
1939    let bindings = load_bindings(connection, &revision_digest)?;
1940    let authorization = load_authorization(connection, &revision_digest)?;
1941    Ok(StrategyDefinition {
1942        summary: StrategyDefinitionSummary {
1943            definition_id,
1944            revision_digest,
1945            semantics_digest,
1946            name,
1947            version,
1948            imported_at_unix_ms: imported_at,
1949            authorized: authorization.as_ref().is_some_and(|value| value.active),
1950        },
1951        workflow,
1952        asset_count: asset_count as usize,
1953        bindings,
1954        authorization,
1955    })
1956}
1957
1958fn summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StrategyDefinitionSummary> {
1959    Ok(StrategyDefinitionSummary {
1960        definition_id: row.get(0)?,
1961        revision_digest: row.get(1)?,
1962        semantics_digest: row.get(2)?,
1963        name: row.get(3)?,
1964        version: row.get(4)?,
1965        imported_at_unix_ms: row.get(5)?,
1966        authorized: row
1967            .get::<_, i64>(6)
1968            .map(|value| value != 0)
1969            .unwrap_or(false),
1970    })
1971}
1972
1973fn load_bindings(connection: &Connection, revision: &str) -> Result<Vec<BindingValue>> {
1974    let mut statement = connection.prepare(
1975        "SELECT slot_id, ordinal, value_id, model, reasoning_effort, revision
1976         FROM strategy_bindings
1977         WHERE revision_digest=?1 ORDER BY slot_id ASC, ordinal ASC",
1978    )?;
1979    let rows = statement.query_map(params![revision], |row| {
1980        Ok(BindingValue {
1981            slot_id: row.get(0)?,
1982            ordinal: row.get::<_, i64>(1)? as u8,
1983            value_id: row.get(2)?,
1984            model: row.get(3)?,
1985            reasoning_effort: row.get(4)?,
1986            revision: row.get::<_, i64>(5)? as u64,
1987        })
1988    })?;
1989    rows.collect::<rusqlite::Result<Vec<_>>>()
1990        .map_err(Into::into)
1991}
1992
1993fn load_authorization(
1994    connection: &Connection,
1995    revision: &str,
1996) -> Result<Option<StrategyAuthorization>> {
1997    connection
1998        .query_row(
1999            "SELECT revision, semantics_digest, binding_digest, authorization_digest, active
2000             FROM strategy_authorizations WHERE revision_digest=?1
2001             ORDER BY revision DESC LIMIT 1",
2002            params![revision],
2003            |row| {
2004                Ok(StrategyAuthorization {
2005                    definition_digest: revision.to_owned(),
2006                    revision: row.get::<_, i64>(0)? as u64,
2007                    semantics_digest: row.get(1)?,
2008                    binding_digest: row.get(2)?,
2009                    authorization_digest: row.get(3)?,
2010                    active: row.get::<_, i64>(4)? != 0,
2011                })
2012            },
2013        )
2014        .optional()
2015        .map_err(Into::into)
2016}
2017
2018fn current_authorization(definition: &StrategyDefinition) -> Result<&StrategyAuthorization> {
2019    definition
2020        .authorization
2021        .as_ref()
2022        .filter(|authorization| {
2023            authorization.active
2024                && authorization.definition_digest == definition.summary.revision_digest
2025                && authorization.semantics_digest == definition.summary.semantics_digest
2026        })
2027        .ok_or_else(|| anyhow!("strategy_authorization_required"))
2028}
2029
2030fn bindings_complete(definition: &StrategyDefinition) -> bool {
2031    let bound = definition
2032        .bindings
2033        .iter()
2034        .filter(|binding| binding.ordinal == 0)
2035        .map(|binding| binding.slot_id.as_str())
2036        .collect::<BTreeSet<_>>();
2037    definition
2038        .workflow
2039        .actor_slots
2040        .iter()
2041        .filter(|slot| slot.required)
2042        .all(|slot| bound.contains(slot.id.as_str()))
2043}
2044
2045fn binding_digest(bindings: &[BindingValue]) -> Result<String> {
2046    let mut sorted = bindings.to_vec();
2047    sorted.sort_by(|left, right| {
2048        left.slot_id
2049            .cmp(&right.slot_id)
2050            .then(left.ordinal.cmp(&right.ordinal))
2051    });
2052    Ok(sha256_hex(&serde_json::to_vec(&sorted)?))
2053}
2054
2055fn slot_candidate_counts(bindings: &[BindingValue]) -> BTreeMap<String, u8> {
2056    let mut counts = BTreeMap::new();
2057    for binding in bindings {
2058        let entry = counts.entry(binding.slot_id.clone()).or_insert(0);
2059        *entry = (*entry).max(binding.ordinal.saturating_add(1));
2060    }
2061    counts
2062}
2063
2064fn authorization_digest(
2065    revision: &str,
2066    semantics: &str,
2067    bindings: &str,
2068    authorization_revision: u64,
2069) -> String {
2070    sha256_hex(
2071        format!(
2072            "licoup-strategy-authorization-v1\0{revision}\0{semantics}\0{bindings}\0{authorization_revision}"
2073        )
2074        .as_bytes(),
2075    )
2076}
2077
2078fn workflow_for_revision(connection: &Connection, revision: &str) -> Result<WorkflowDefinition> {
2079    let value: String = connection
2080        .query_row(
2081            "SELECT workflow_json FROM strategy_definitions WHERE revision_digest=?1",
2082            params![revision],
2083            |row| row.get(0),
2084        )
2085        .optional()?
2086        .ok_or_else(|| anyhow!("strategy_definition_not_found"))?;
2087    serde_json::from_str(&value).map_err(Into::into)
2088}
2089
2090fn load_run(connection: &Connection, run_id: &str) -> Result<RunSnapshot> {
2091    let value: Option<String> = connection
2092        .query_row(
2093            "SELECT snapshot_json FROM strategy_runs WHERE run_id=?1",
2094            params![run_id],
2095            |row| row.get(0),
2096        )
2097        .optional()?;
2098    serde_json::from_str(
2099        value
2100            .as_deref()
2101            .ok_or_else(|| anyhow!("strategy_run_not_found"))?,
2102    )
2103    .map_err(Into::into)
2104}
2105
2106fn persist_event_and_commands(
2107    transaction: &Transaction<'_>,
2108    before: &RunSnapshot,
2109    snapshot: &RunSnapshot,
2110    event: &ReducerEvent,
2111    emitted: &[RunCommand],
2112    now: i64,
2113) -> Result<()> {
2114    let event_json = serde_json::to_string(event)?;
2115    let event_type = event_json.split('"').nth(3).unwrap_or("event").to_owned();
2116    transaction.execute(
2117        "INSERT INTO strategy_run_events(run_id, sequence, event_type, event_json, created_at)
2118         VALUES (?1, ?2, ?3, ?4, ?5)",
2119        params![
2120            snapshot.run_id,
2121            snapshot.sequence as i64,
2122            event_type,
2123            event_json,
2124            now
2125        ],
2126    )?;
2127    super::commit::insert_intent(
2128        transaction,
2129        &snapshot.run_id,
2130        snapshot.sequence,
2131        event,
2132        before,
2133        snapshot,
2134        now,
2135    )?;
2136    for command in emitted {
2137        transaction.execute(
2138            "INSERT INTO strategy_commands(
2139               command_id, run_id, state_id, kind, status, attempt,
2140               attempt_token, command_json, updated_at
2141             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
2142            params![
2143                command.id,
2144                snapshot.run_id,
2145                command.state_id,
2146                enum_wire(command.kind)?,
2147                enum_wire(command.status)?,
2148                command.attempt as i64,
2149                command.attempt_token,
2150                serde_json::to_string(command)?,
2151                now,
2152            ],
2153        )?;
2154    }
2155    for command in snapshot.commands.values() {
2156        transaction.execute(
2157            "UPDATE strategy_commands SET status=?2, command_json=?3, updated_at=?4
2158             WHERE command_id=?1",
2159            params![
2160                command.id,
2161                enum_wire(command.status)?,
2162                serde_json::to_string(command)?,
2163                now,
2164            ],
2165        )?;
2166    }
2167    Ok(())
2168}
2169
2170fn enum_wire(value: impl serde::Serialize) -> Result<String> {
2171    serde_json::to_value(value)?
2172        .as_str()
2173        .map(str::to_owned)
2174        .ok_or_else(|| anyhow!("strategy_enum_invalid"))
2175}
2176
2177fn validate_opaque_id(value: &str, code: &'static str) -> Result<()> {
2178    ensure!(
2179        value == value.trim()
2180            && !value.is_empty()
2181            && value.len() <= 160
2182            && !value.chars().any(char::is_control),
2183        code
2184    );
2185    Ok(())
2186}
2187
2188fn validate_optional_text(value: &str, code: &'static str) -> Result<()> {
2189    ensure!(
2190        value == value.trim() && value.len() <= 160 && !value.chars().any(char::is_control),
2191        code
2192    );
2193    Ok(())
2194}
2195
2196fn ensure_column(
2197    connection: &Connection,
2198    table: &str,
2199    column: &str,
2200    declaration: &str,
2201) -> Result<()> {
2202    let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
2203    let existing = statement
2204        .query_map([], |row| row.get::<_, String>(1))?
2205        .collect::<rusqlite::Result<Vec<_>>>()?;
2206    if !existing.iter().any(|value| value == column) {
2207        connection.execute_batch(&format!(
2208            "ALTER TABLE {table} ADD COLUMN {column} {declaration}"
2209        ))?;
2210    }
2211    Ok(())
2212}
2213
2214fn recovery_for_status(status: StrategyRunStatus) -> &'static str {
2215    match status {
2216        StrategyRunStatus::AuthorizationRequired => "Review and authorize the current semantics.",
2217        StrategyRunStatus::RuntimeMissing => "Bind an available verified local runtime.",
2218        StrategyRunStatus::Retryable => "Retry the failed command.",
2219        StrategyRunStatus::CancelInDoubt => "Inspect the external effect before continuing.",
2220        _ => "Inspect the run history.",
2221    }
2222}
2223
2224fn sha256_hex(bytes: &[u8]) -> String {
2225    Sha256::digest(bytes)
2226        .iter()
2227        .map(|byte| format!("{byte:02x}"))
2228        .collect()
2229}
2230
2231fn now_ms() -> i64 {
2232    std::time::SystemTime::now()
2233        .duration_since(std::time::UNIX_EPOCH)
2234        .map(|duration| duration.as_millis() as i64)
2235        .unwrap_or(0)
2236}
2237
2238fn remove_directory_tree(path: &Path) {
2239    let mut stack = vec![path.to_path_buf()];
2240    while let Some(current) = stack.pop() {
2241        if let Ok(metadata) = fs::symlink_metadata(&current) {
2242            make_path_writable(&current, metadata.permissions());
2243            if metadata.is_dir()
2244                && let Ok(entries) = fs::read_dir(&current)
2245            {
2246                stack.extend(entries.flatten().map(|entry| entry.path()));
2247            }
2248        }
2249    }
2250    let _ = fs::remove_dir_all(path);
2251}
2252
2253fn make_path_writable(path: &Path, mut permissions: fs::Permissions) {
2254    #[cfg(unix)]
2255    {
2256        use std::os::unix::fs::PermissionsExt;
2257        permissions.set_mode(permissions.mode() | 0o200);
2258    }
2259    #[cfg(not(unix))]
2260    permissions.set_readonly(false);
2261    let _ = fs::set_permissions(path, permissions);
2262}
2263
2264#[cfg(test)]
2265mod tests {
2266    use super::*;
2267    use licoup_workflow::{
2268        ActorSlot, GraphState, GraphStateKind, RetryPolicy, Transition, TransitionEvent,
2269        TransitionMode, WorkflowLimits, WorkflowMetadata,
2270    };
2271    use serde_json::json;
2272
2273    fn workflow() -> WorkflowDefinition {
2274        WorkflowDefinition {
2275            schema: licoup_workflow::WORKFLOW_SCHEMA_VERSION.into(),
2276            metadata: WorkflowMetadata {
2277                id: "test".into(),
2278                name: "Test".into(),
2279                version: "1".into(),
2280                description: String::new(),
2281            },
2282            limits: WorkflowLimits::default(),
2283            actor_slots: vec![ActorSlot::required_actor("worker", "Worker")],
2284            runtimes: vec![],
2285            worksets: vec![],
2286            initial: "work".into(),
2287            states: vec![
2288                GraphState {
2289                    id: "work".into(),
2290                    kind: GraphStateKind::Actor,
2291                    label: "Work".into(),
2292                    instruction: String::new(),
2293                    binding: Some("worker".into()),
2294                    runtime: None,
2295                    entry: None,
2296                    workset: None,
2297                    retry: RetryPolicy {
2298                        max_attempts: 2,
2299                        transient_only: true,
2300                    },
2301                },
2302                GraphState {
2303                    id: "done".into(),
2304                    kind: GraphStateKind::Succeed,
2305                    label: "Done".into(),
2306                    instruction: String::new(),
2307                    binding: None,
2308                    runtime: None,
2309                    entry: None,
2310                    workset: None,
2311                    retry: RetryPolicy::default(),
2312                },
2313                GraphState {
2314                    id: "fail".into(),
2315                    kind: GraphStateKind::Fail,
2316                    label: "Fail".into(),
2317                    instruction: String::new(),
2318                    binding: None,
2319                    runtime: None,
2320                    entry: None,
2321                    workset: None,
2322                    retry: RetryPolicy::default(),
2323                },
2324            ],
2325            transitions: vec![
2326                Transition {
2327                    id: "done".into(),
2328                    from: "work".into(),
2329                    to: "done".into(),
2330                    event: TransitionEvent::Success,
2331                    mode: TransitionMode::Flow,
2332                    guard: None,
2333                },
2334                Transition {
2335                    id: "failed".into(),
2336                    from: "work".into(),
2337                    to: "fail".into(),
2338                    event: TransitionEvent::Failure,
2339                    mode: TransitionMode::Flow,
2340                    guard: None,
2341                },
2342            ],
2343        }
2344    }
2345
2346    #[test]
2347    fn binding_ordinal_migration_invalidates_existing_authorizations() {
2348        let mut connection = Connection::open_in_memory().unwrap();
2349        connection
2350            .execute_batch(
2351                "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
2352                 INSERT INTO strategy_meta(key, value) VALUES ('version', '1');
2353                 CREATE TABLE strategy_definitions(
2354                   definition_id TEXT NOT NULL,
2355                   revision_digest TEXT PRIMARY KEY,
2356                   semantics_digest TEXT NOT NULL,
2357                   name TEXT NOT NULL,
2358                   version TEXT NOT NULL,
2359                   workflow_json TEXT NOT NULL,
2360                   asset_count INTEGER NOT NULL,
2361                   imported_at INTEGER NOT NULL
2362                 );
2363                 CREATE TABLE strategy_bindings(
2364                   revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
2365                   slot_id TEXT NOT NULL,
2366                   value_id TEXT NOT NULL,
2367                   model TEXT NOT NULL DEFAULT '',
2368                   reasoning_effort TEXT NOT NULL DEFAULT '',
2369                   revision INTEGER NOT NULL,
2370                   PRIMARY KEY(revision_digest, slot_id)
2371                 );
2372                 CREATE TABLE strategy_authorizations(
2373                   revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest) ON DELETE CASCADE,
2374                   revision INTEGER NOT NULL,
2375                   semantics_digest TEXT NOT NULL,
2376                   binding_digest TEXT NOT NULL,
2377                   authorization_digest TEXT NOT NULL,
2378                   active INTEGER NOT NULL,
2379                   created_at INTEGER NOT NULL,
2380                   PRIMARY KEY(revision_digest, revision)
2381                 );
2382                 INSERT INTO strategy_definitions VALUES (
2383                   'strategy', 'revision', 'semantics', 'Strategy', '1', '{}', 0, 1
2384                 );
2385                 INSERT INTO strategy_bindings VALUES (
2386                   'revision', 'worker', 'agent:test', '', '', 1
2387                 );
2388                 INSERT INTO strategy_authorizations VALUES (
2389                   'revision', 1, 'semantics', 'old-bindings', 'authorization', 1, 1
2390                 );",
2391            )
2392            .unwrap();
2393
2394        let retired = initialize_schema(&mut connection).unwrap();
2395        assert!(retired.is_empty());
2396
2397        let (ordinal, active, version): (i64, i64, String) = connection
2398            .query_row(
2399                "SELECT b.ordinal, a.active, m.value
2400                   FROM strategy_bindings b
2401                   JOIN strategy_authorizations a USING (revision_digest)
2402                   JOIN strategy_meta m ON m.key='version'",
2403                [],
2404                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2405            )
2406            .unwrap();
2407        assert_eq!(ordinal, 0);
2408        assert_eq!(active, 0);
2409        assert_eq!(version, "3");
2410        validate_published_core_layout(&connection, "3").unwrap();
2411    }
2412
2413    #[test]
2414    fn ordinary_open_rejects_a_legacy_strategy_schema() {
2415        let root = std::env::temp_dir().join(format!("lico-strategy-legacy-{}", Uuid::new_v4()));
2416        let database = root.join("client-state/adaptive-flywheel/strategies.sqlite3");
2417        fs::create_dir_all(database.parent().unwrap()).unwrap();
2418        let connection = Connection::open(&database).unwrap();
2419        connection
2420            .execute_batch(
2421                "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
2422                 INSERT INTO strategy_meta(key, value) VALUES ('version', '1');",
2423            )
2424            .unwrap();
2425        drop(connection);
2426        assert!(StrategyStore::open(&root).is_err());
2427        StrategyStore::open_for_migration(&root).unwrap();
2428        StrategyStore::open(&root).unwrap();
2429        let _ = fs::remove_dir_all(root);
2430    }
2431
2432    #[test]
2433    fn typed_migration_materializes_legacy_workflow_routing_once() {
2434        let root =
2435            std::env::temp_dir().join(format!("lico-strategy-workflow-routing-{}", Uuid::new_v4()));
2436        let database = root.join("client-state/adaptive-flywheel/strategies.sqlite3");
2437        fs::create_dir_all(database.parent().unwrap()).unwrap();
2438        let connection = Connection::open(&database).unwrap();
2439        connection
2440            .execute_batch(
2441                "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
2442                 INSERT INTO strategy_meta(key, value) VALUES ('version', '2');
2443                 CREATE TABLE strategy_definitions(
2444                   definition_id TEXT NOT NULL,
2445                   revision_digest TEXT PRIMARY KEY,
2446                   semantics_digest TEXT NOT NULL,
2447                   name TEXT NOT NULL,
2448                   version TEXT NOT NULL,
2449                   workflow_json TEXT NOT NULL,
2450                   asset_count INTEGER NOT NULL,
2451                   imported_at INTEGER NOT NULL
2452                 );",
2453            )
2454            .unwrap();
2455
2456        let mut success_only = workflow();
2457        success_only.actor_slots[0].entry = false;
2458        success_only
2459            .transitions
2460            .retain(|transition| transition.event != TransitionEvent::Failure);
2461        let mut complete_as_failure = success_only.clone();
2462        complete_as_failure.transitions.push(Transition {
2463            id: "legacy-complete".into(),
2464            from: "work".into(),
2465            to: "fail".into(),
2466            event: TransitionEvent::Complete,
2467            mode: TransitionMode::Flow,
2468            guard: None,
2469        });
2470        let mut redundant_complete = workflow();
2471        redundant_complete.actor_slots[0].entry = false;
2472        redundant_complete.transitions.push(Transition {
2473            id: "legacy-complete".into(),
2474            from: "work".into(),
2475            to: "fail".into(),
2476            event: TransitionEvent::Complete,
2477            mode: TransitionMode::Flow,
2478            guard: None,
2479        });
2480        for (revision, definition) in [
2481            ("legacy-success", success_only),
2482            ("legacy-convert", complete_as_failure),
2483            ("legacy-redundant", redundant_complete),
2484        ] {
2485            connection
2486                .execute(
2487                    "INSERT INTO strategy_definitions VALUES (?1, ?1, 'legacy', ?1, '1', ?2, 0, 1)",
2488                    params![revision, serde_json::to_string(&definition).unwrap()],
2489                )
2490                .unwrap();
2491        }
2492        drop(connection);
2493
2494        assert!(StrategyStore::open(&root).is_err());
2495        StrategyStore::open_for_migration(&root).unwrap();
2496        let store = StrategyStore::open(&root).unwrap();
2497        for revision in ["legacy-success", "legacy-convert", "legacy-redundant"] {
2498            let definition = store.definition_by_revision(revision).unwrap();
2499            assert!(definition.workflow.actor_slots[0].entry);
2500            assert_eq!(
2501                definition
2502                    .workflow
2503                    .transitions
2504                    .iter()
2505                    .filter(|transition| {
2506                        transition.from == "work" && transition.event == TransitionEvent::Failure
2507                    })
2508                    .count(),
2509                1
2510            );
2511            assert!(definition.workflow.transitions.iter().all(|transition| {
2512                transition.from != "work" || transition.event != TransitionEvent::Complete
2513            }));
2514            assert!(compile_workflow(definition.workflow).is_ok());
2515        }
2516        let _ = fs::remove_dir_all(root);
2517    }
2518
2519    #[test]
2520    fn workflow_migration_preserves_live_run_and_authorization_identity() {
2521        let store = StrategyStore::open_in_memory().unwrap();
2522        let revision = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
2523        let semantics = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
2524        let current = workflow();
2525        store
2526            .register_definition(revision, semantics, &current, 1, 1)
2527            .unwrap();
2528        store
2529            .update_binding(revision, "worker", "agent:test", "", "", None)
2530            .unwrap();
2531        let preview = store.authorization_preview(revision).unwrap();
2532        let authorization = store
2533            .grant_authorization(revision, &preview.authorization_digest)
2534            .unwrap();
2535        let run = store
2536            .start_run(revision, json!({}), "migration-live-run", None, None)
2537            .unwrap();
2538        let claimed = store
2539            .claim_next_command(&run.run_id, "migration-host", now_ms() + 60_000)
2540            .unwrap()
2541            .unwrap();
2542        store
2543            .apply_event(
2544                &run.run_id,
2545                ReducerEvent::CommandStarted {
2546                    command_id: claimed.id.clone(),
2547                    attempt_token: claimed.attempt_token.clone(),
2548                },
2549            )
2550            .unwrap();
2551
2552        let mut legacy = current;
2553        legacy.actor_slots[0].entry = false;
2554        legacy
2555            .transitions
2556            .retain(|transition| transition.event != TransitionEvent::Failure);
2557        let before = store
2558            .with_connection(|connection| {
2559                connection.execute(
2560                    "UPDATE strategy_definitions SET workflow_json=?2 WHERE revision_digest=?1",
2561                    params![revision, serde_json::to_string(&legacy)?],
2562                )?;
2563                connection.execute("UPDATE strategy_meta SET value='2' WHERE key='version'", [])?;
2564                let run_row = connection.query_row(
2565                    "SELECT semantics_digest, snapshot_json FROM strategy_runs WHERE run_id=?1",
2566                    params![run.run_id],
2567                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
2568                )?;
2569                let command_row = connection.query_row(
2570                    "SELECT status, attempt, attempt_token, command_json, lease_owner, lease_until
2571                       FROM strategy_commands WHERE command_id=?1",
2572                    params![claimed.id],
2573                    |row| {
2574                        Ok((
2575                            row.get::<_, String>(0)?,
2576                            row.get::<_, i64>(1)?,
2577                            row.get::<_, String>(2)?,
2578                            row.get::<_, String>(3)?,
2579                            row.get::<_, Option<String>>(4)?,
2580                            row.get::<_, Option<i64>>(5)?,
2581                        ))
2582                    },
2583                )?;
2584                let authorization_row = connection.query_row(
2585                    "SELECT semantics_digest, binding_digest, authorization_digest, active
2586                       FROM strategy_authorizations WHERE revision_digest=?1",
2587                    params![revision],
2588                    |row| {
2589                        Ok((
2590                            row.get::<_, String>(0)?,
2591                            row.get::<_, String>(1)?,
2592                            row.get::<_, String>(2)?,
2593                            row.get::<_, i64>(3)?,
2594                        ))
2595                    },
2596                )?;
2597                Ok((run_row, command_row, authorization_row))
2598            })
2599            .unwrap();
2600
2601        store.with_connection(initialize_schema).unwrap();
2602
2603        let after = store
2604            .with_connection(|connection| {
2605                let run_row = connection.query_row(
2606                    "SELECT semantics_digest, snapshot_json FROM strategy_runs WHERE run_id=?1",
2607                    params![run.run_id],
2608                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
2609                )?;
2610                let command_row = connection.query_row(
2611                    "SELECT status, attempt, attempt_token, command_json, lease_owner, lease_until
2612                       FROM strategy_commands WHERE command_id=?1",
2613                    params![claimed.id],
2614                    |row| {
2615                        Ok((
2616                            row.get::<_, String>(0)?,
2617                            row.get::<_, i64>(1)?,
2618                            row.get::<_, String>(2)?,
2619                            row.get::<_, String>(3)?,
2620                            row.get::<_, Option<String>>(4)?,
2621                            row.get::<_, Option<i64>>(5)?,
2622                        ))
2623                    },
2624                )?;
2625                let authorization_row = connection.query_row(
2626                    "SELECT semantics_digest, binding_digest, authorization_digest, active
2627                       FROM strategy_authorizations WHERE revision_digest=?1",
2628                    params![revision],
2629                    |row| {
2630                        Ok((
2631                            row.get::<_, String>(0)?,
2632                            row.get::<_, String>(1)?,
2633                            row.get::<_, String>(2)?,
2634                            row.get::<_, i64>(3)?,
2635                        ))
2636                    },
2637                )?;
2638                Ok((run_row, command_row, authorization_row))
2639            })
2640            .unwrap();
2641        assert_eq!(after, before);
2642        assert_eq!(after.0.0, semantics);
2643        assert_eq!(after.1.0, "running");
2644        assert_eq!(after.1.2, claimed.attempt_token);
2645        assert_eq!(after.2.2, authorization.authorization_digest);
2646        assert_eq!(after.2.3, 1);
2647
2648        let migrated = store.definition_by_revision(revision).unwrap();
2649        assert_eq!(migrated.summary.revision_digest, revision);
2650        assert_eq!(migrated.summary.semantics_digest, semantics);
2651        assert!(migrated.workflow.actor_slots[0].entry);
2652        assert!(compile_workflow(migrated.workflow).is_ok());
2653    }
2654
2655    #[test]
2656    fn workflow_routing_migration_rolls_back_all_rows_when_a_later_row_is_invalid() {
2657        let store = StrategyStore::open_in_memory().unwrap();
2658        let mut legacy = workflow();
2659        legacy.actor_slots[0].entry = false;
2660        legacy
2661            .transitions
2662            .retain(|transition| transition.event != TransitionEvent::Failure);
2663        let legacy_json = serde_json::to_string(&legacy).unwrap();
2664        let mut invalid = legacy.clone();
2665        invalid.initial = "missing-state".into();
2666        let invalid_json = serde_json::to_string(&invalid).unwrap();
2667        store
2668            .with_connection(|connection| {
2669                connection.execute("UPDATE strategy_meta SET value='2' WHERE key='version'", [])?;
2670                for (definition, revision, workflow_json) in [
2671                    ("first", "a-valid-legacy", &legacy_json),
2672                    ("later", "z-invalid-legacy", &invalid_json),
2673                ] {
2674                    connection.execute(
2675                        "INSERT INTO strategy_definitions(
2676                           definition_id, revision_digest, semantics_digest, name, version,
2677                           workflow_json, asset_count, imported_at
2678                         ) VALUES (?1, ?2, 'legacy-semantics', ?1, '1', ?3, 0, 1)",
2679                        params![definition, revision, workflow_json],
2680                    )?;
2681                }
2682                Ok(())
2683            })
2684            .unwrap();
2685
2686        assert!(store.with_connection(initialize_schema).is_err());
2687        store
2688            .with_connection(|connection| {
2689                let version: String = connection.query_row(
2690                    "SELECT value FROM strategy_meta WHERE key='version'",
2691                    [],
2692                    |row| row.get(0),
2693                )?;
2694                let first: String = connection.query_row(
2695                    "SELECT workflow_json FROM strategy_definitions WHERE revision_digest='a-valid-legacy'",
2696                    [],
2697                    |row| row.get(0),
2698                )?;
2699                assert_eq!(version, "2");
2700                assert_eq!(first, legacy_json);
2701                Ok(())
2702            })
2703            .unwrap();
2704    }
2705
2706    #[test]
2707    fn event_snapshot_and_outbox_commit_together() {
2708        let store = StrategyStore::open_in_memory().unwrap();
2709        store
2710            .register_definition(
2711                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2712                "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2713                &workflow(),
2714                1,
2715                1,
2716            )
2717            .unwrap();
2718        let binding = store
2719            .update_binding(
2720                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2721                "worker",
2722                "agent:test",
2723                "gpt-5",
2724                "high",
2725                None,
2726            )
2727            .unwrap();
2728        assert_eq!(binding.model, "gpt-5");
2729        assert_eq!(binding.reasoning_effort, "high");
2730        let persisted = store
2731            .definition_by_revision(
2732                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2733            )
2734            .unwrap();
2735        assert_eq!(persisted.bindings[0].ordinal, 0);
2736        assert_eq!(persisted.bindings[0].model, "gpt-5");
2737        assert_eq!(persisted.bindings[0].reasoning_effort, "high");
2738        let chain = store
2739            .replace_slot_bindings(
2740                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2741                "worker",
2742                &[
2743                    BindingCandidate {
2744                        value_id: "agent:primary".into(),
2745                        model: "model-a".into(),
2746                        reasoning_effort: "high".into(),
2747                    },
2748                    BindingCandidate {
2749                        value_id: "agent:fallback".into(),
2750                        model: "model-b".into(),
2751                        reasoning_effort: String::new(),
2752                    },
2753                ],
2754                Some(binding.revision),
2755            )
2756            .unwrap();
2757        assert_eq!(chain.len(), 2);
2758        assert_eq!(chain[0].ordinal, 0);
2759        assert_eq!(chain[1].ordinal, 1);
2760        assert_eq!(chain[1].value_id, "agent:fallback");
2761        let preview = store
2762            .authorization_preview(
2763                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2764            )
2765            .unwrap();
2766        store
2767            .grant_authorization(
2768                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2769                &preview.authorization_digest,
2770            )
2771            .unwrap();
2772        let run = store
2773            .start_run(
2774                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2775                json!({}),
2776                "idempotency-test",
2777                Some("conversation:test"),
2778                None,
2779            )
2780            .unwrap();
2781        assert_eq!(run.commands.len(), 1);
2782        assert_eq!(
2783            store
2784                .active_run_for_conversation(
2785                    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2786                    "conversation:test",
2787                )
2788                .unwrap()
2789                .map(|snapshot| snapshot.run_id),
2790            Some(run.run_id.clone())
2791        );
2792        let indexed: (String, i64) = store
2793            .with_connection(|connection| {
2794                connection
2795                    .query_row(
2796                        "SELECT conversation_id, terminal FROM strategy_runs WHERE run_id=?1",
2797                        params![run.run_id],
2798                        |row| Ok((row.get(0)?, row.get(1)?)),
2799                    )
2800                    .map_err(Into::into)
2801            })
2802            .unwrap();
2803        assert_eq!(indexed, ("conversation:test".to_owned(), 0));
2804        let replay = store
2805            .start_run(
2806                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2807                json!({}),
2808                "idempotency-test",
2809                Some("conversation:test"),
2810                None,
2811            )
2812            .unwrap();
2813        assert_eq!(run, replay);
2814    }
2815
2816    #[test]
2817    fn effect_authorization_revalidates_digest_owner_and_live_lease() {
2818        let store = StrategyStore::open_in_memory().unwrap();
2819        let revision = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
2820        store
2821            .register_definition(revision, revision, &workflow(), 1, 1)
2822            .unwrap();
2823        store
2824            .update_binding(revision, "worker", "agent:test", "", "", None)
2825            .unwrap();
2826        let preview = store.authorization_preview(revision).unwrap();
2827        let authorization = store
2828            .grant_authorization(revision, &preview.authorization_digest)
2829            .unwrap();
2830        let run = store
2831            .start_run(revision, json!({}), "authorization-fence", None, None)
2832            .unwrap();
2833        let command = store
2834            .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
2835            .unwrap()
2836            .unwrap();
2837        store
2838            .apply_event(
2839                &run.run_id,
2840                ReducerEvent::CommandStarted {
2841                    command_id: command.id.clone(),
2842                    attempt_token: command.attempt_token.clone(),
2843                },
2844            )
2845            .unwrap();
2846        store
2847            .authorize_effect(
2848                &run.run_id,
2849                &command.id,
2850                &command.attempt_token,
2851                &authorization.authorization_digest,
2852                "claimant",
2853                now_ms() + 60_000,
2854            )
2855            .unwrap();
2856        assert!(
2857            store
2858                .authorize_effect(
2859                    &run.run_id,
2860                    &command.id,
2861                    &command.attempt_token,
2862                    &authorization.authorization_digest,
2863                    "other-claimant",
2864                    now_ms() + 60_000,
2865                )
2866                .is_err()
2867        );
2868        store.revoke_authorization(revision).unwrap();
2869        assert!(
2870            store
2871                .authorize_effect(
2872                    &run.run_id,
2873                    &command.id,
2874                    &command.attempt_token,
2875                    &authorization.authorization_digest,
2876                    "claimant",
2877                    now_ms() + 60_000,
2878                )
2879                .is_err()
2880        );
2881        let next = store.authorization_preview(revision).unwrap();
2882        let next = store
2883            .grant_authorization(revision, &next.authorization_digest)
2884            .unwrap();
2885        assert!(
2886            store
2887                .authorize_effect(
2888                    &run.run_id,
2889                    &command.id,
2890                    &command.attempt_token,
2891                    &authorization.authorization_digest,
2892                    "claimant",
2893                    now_ms() + 60_000,
2894                )
2895                .is_err()
2896        );
2897        store
2898            .authorize_effect(
2899                &run.run_id,
2900                &command.id,
2901                &command.attempt_token,
2902                &next.authorization_digest,
2903                "claimant",
2904                now_ms() + 60_000,
2905            )
2906            .unwrap();
2907        let connection = Connection::open(store.db_path()).unwrap();
2908        connection
2909            .execute(
2910                "UPDATE strategy_commands SET lease_until=0 WHERE command_id=?1",
2911                params![&command.id],
2912            )
2913            .unwrap();
2914        assert!(
2915            store
2916                .authorize_effect(
2917                    &run.run_id,
2918                    &command.id,
2919                    &command.attempt_token,
2920                    &next.authorization_digest,
2921                    "claimant",
2922                    now_ms() + 60_000,
2923                )
2924                .is_err(),
2925            "an expired running lease must never issue an effect permit"
2926        );
2927    }
2928
2929    #[test]
2930    fn expired_claim_recovery_is_atomic_and_retries_before_start() {
2931        let store = StrategyStore::open_in_memory().unwrap();
2932        let revision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2933        store
2934            .register_definition(
2935                revision,
2936                "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2937                &workflow(),
2938                1,
2939                1,
2940            )
2941            .unwrap();
2942        store
2943            .update_binding(revision, "worker", "agent:test", "", "", None)
2944            .unwrap();
2945        let preview = store.authorization_preview(revision).unwrap();
2946        store
2947            .grant_authorization(revision, &preview.authorization_digest)
2948            .unwrap();
2949        let run = store
2950            .start_run(revision, json!({}), "recovery-test", None, None)
2951            .unwrap();
2952        let claimed = store
2953            .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
2954            .unwrap()
2955            .unwrap();
2956        let connection = Connection::open(store.db_path()).unwrap();
2957        connection
2958            .execute(
2959                "UPDATE strategy_commands SET lease_until=0 WHERE command_id=?1",
2960                params![claimed.id],
2961            )
2962            .unwrap();
2963
2964        assert!(store.recover_next_expired_command(&run.run_id).unwrap());
2965        assert!(!store.recover_next_expired_command(&run.run_id).unwrap());
2966        assert!(
2967            store
2968                .renew_command_lease(&claimed.id, "claimant", now_ms() + 60_000)
2969                .is_err()
2970        );
2971        let recovered = store.run(&run.run_id).unwrap();
2972        assert_eq!(
2973            recovered.commands[&claimed.id].status,
2974            CommandStatus::Cancelled
2975        );
2976        assert!(
2977            recovered.commands.values().any(|command| {
2978                command.attempt == 2 && command.status == CommandStatus::Pending
2979            })
2980        );
2981    }
2982
2983    #[test]
2984    fn expired_running_effect_is_fenced_in_doubt_without_retry() {
2985        let store = StrategyStore::open_in_memory().unwrap();
2986        let revision = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
2987        store
2988            .register_definition(
2989                revision,
2990                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
2991                &workflow(),
2992                1,
2993                1,
2994            )
2995            .unwrap();
2996        store
2997            .update_binding(revision, "worker", "agent:test", "", "", None)
2998            .unwrap();
2999        let preview = store.authorization_preview(revision).unwrap();
3000        store
3001            .grant_authorization(revision, &preview.authorization_digest)
3002            .unwrap();
3003        let run = store
3004            .start_run(revision, json!({}), "running-recovery-test", None, None)
3005            .unwrap();
3006        let claimed = store
3007            .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
3008            .unwrap()
3009            .unwrap();
3010        store
3011            .apply_event(
3012                &run.run_id,
3013                ReducerEvent::CommandStarted {
3014                    command_id: claimed.id.clone(),
3015                    attempt_token: claimed.attempt_token.clone(),
3016                },
3017            )
3018            .unwrap();
3019        let connection = Connection::open(store.db_path()).unwrap();
3020        connection
3021            .execute(
3022                "UPDATE strategy_commands SET lease_until=0 WHERE command_id=?1",
3023                params![claimed.id],
3024            )
3025            .unwrap();
3026
3027        assert!(store.recover_next_expired_command(&run.run_id).unwrap());
3028        let recovered = store.run(&run.run_id).unwrap();
3029        assert_eq!(recovered.status, StrategyRunStatus::CancelInDoubt);
3030        assert_eq!(
3031            recovered.commands[&claimed.id].status,
3032            CommandStatus::InDoubt
3033        );
3034        assert!(
3035            !recovered
3036                .commands
3037                .values()
3038                .any(|command| command.attempt == 2)
3039        );
3040    }
3041
3042    #[test]
3043    fn an_unexpired_claim_stays_held_when_this_host_becomes_the_driver() {
3044        let store = StrategyStore::open_in_memory().unwrap();
3045        let revision = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
3046        store
3047            .register_definition(
3048                revision,
3049                "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
3050                &workflow(),
3051                1,
3052                1,
3053            )
3054            .unwrap();
3055        store
3056            .update_binding(revision, "worker", "agent:test", "", "", None)
3057            .unwrap();
3058        let preview = store.authorization_preview(revision).unwrap();
3059        store
3060            .grant_authorization(revision, &preview.authorization_digest)
3061            .unwrap();
3062        let run = store
3063            .start_run(revision, json!({}), "abandoned-host-recovery", None, None)
3064            .unwrap();
3065        let claimed = store
3066            .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
3067            .unwrap()
3068            .unwrap();
3069        store
3070            .apply_event(
3071                &run.run_id,
3072                ReducerEvent::CommandStarted {
3073                    command_id: claimed.id.clone(),
3074                    attempt_token: claimed.attempt_token.clone(),
3075                },
3076            )
3077            .unwrap();
3078
3079        // A previous host process disappearing is not lease revocation, so the
3080        // still-valid claim is neither released nor retried.
3081        assert!(!store.recover_next_expired_command(&run.run_id).unwrap());
3082        let held = store.run(&run.run_id).unwrap();
3083        assert_eq!(held.commands[&claimed.id].status, CommandStatus::Running);
3084        assert!(held.commands[&claimed.id].failure_code.is_none());
3085        assert!(
3086            !held.commands.values().any(|command| command.attempt == 2),
3087            "an unexpired in-flight effect is never repeated"
3088        );
3089        assert_eq!(held.status, StrategyRunStatus::Running);
3090    }
3091
3092    #[test]
3093    fn a_released_unexpired_claim_never_becomes_retryable() {
3094        let store = StrategyStore::open_in_memory().unwrap();
3095        let revision = "5656565656565656565656565656565656565656565656565656565656565656";
3096        store
3097            .register_definition(
3098                revision,
3099                "7878787878787878787878787878787878787878787878787878787878787878",
3100                &workflow(),
3101                1,
3102                1,
3103            )
3104            .unwrap();
3105        store
3106            .update_binding(revision, "worker", "agent:test", "", "", None)
3107            .unwrap();
3108        let preview = store.authorization_preview(revision).unwrap();
3109        store
3110            .grant_authorization(revision, &preview.authorization_digest)
3111            .unwrap();
3112        let run = store
3113            .start_run(revision, json!({}), "held-claim", None, None)
3114            .unwrap();
3115        let claimed = store
3116            .claim_next_command(&run.run_id, "claimant", now_ms() + 60_000)
3117            .unwrap()
3118            .unwrap();
3119        store
3120            .apply_event(
3121                &run.run_id,
3122                ReducerEvent::CommandStarted {
3123                    command_id: claimed.id.clone(),
3124                    attempt_token: claimed.attempt_token.clone(),
3125                },
3126            )
3127            .unwrap();
3128
3129        // The owner settles its own attempt; nothing else may report on it.
3130        assert!(
3131            store
3132                .apply_event(
3133                    &run.run_id,
3134                    ReducerEvent::CommandSucceeded {
3135                        command_id: claimed.id.clone(),
3136                        attempt_token: String::from("attempt:someone-else"),
3137                        output: json!({ "ok": true }),
3138                    },
3139                )
3140                .is_err(),
3141            "a stale owner result cannot settle an attempt it does not own"
3142        );
3143        let unchanged = store.run(&run.run_id).unwrap();
3144        assert_eq!(
3145            unchanged.commands[&claimed.id].status,
3146            CommandStatus::Running
3147        );
3148        assert!(unchanged.commands[&claimed.id].output_digest.is_none());
3149    }
3150
3151    #[test]
3152    fn bind_conversation_if_absent_fills_an_unbound_run() {
3153        let store = StrategyStore::open_in_memory().unwrap();
3154        let revision = "1111111111111111111111111111111111111111111111111111111111111111";
3155        store
3156            .register_definition(
3157                revision,
3158                "2222222222222222222222222222222222222222222222222222222222222222",
3159                &workflow(),
3160                1,
3161                1,
3162            )
3163            .unwrap();
3164        store
3165            .update_binding(revision, "worker", "agent:test", "", "", None)
3166            .unwrap();
3167        let preview = store.authorization_preview(revision).unwrap();
3168        store
3169            .grant_authorization(revision, &preview.authorization_digest)
3170            .unwrap();
3171        let run = store
3172            .start_run(revision, json!({}), "bind-conversation", None, None)
3173            .unwrap();
3174        assert!(run.conversation_id.is_none());
3175        store
3176            .bind_conversation_if_absent(&run.run_id, "conversation:group")
3177            .unwrap();
3178        store
3179            .bind_conversation_if_absent(&run.run_id, "conversation:other")
3180            .unwrap();
3181        let bound = store.run(&run.run_id).unwrap();
3182        assert_eq!(bound.conversation_id.as_deref(), Some("conversation:group"));
3183    }
3184
3185    fn digest(fill: char) -> String {
3186        fill.to_string().repeat(64)
3187    }
3188
3189    #[test]
3190    fn retired_builtin_definition_is_deleted_by_the_typed_migration() {
3191        let root =
3192            std::env::temp_dir().join(format!("lico-adaptive-flywheel-retire-{}", Uuid::new_v4()));
3193        let database = root.join("client-state/adaptive-flywheel/strategies.sqlite3");
3194        fs::create_dir_all(database.parent().unwrap()).unwrap();
3195        let retired_id_digest = digest('a');
3196        let retired_name_digest = digest('b');
3197        let kept_digest = digest('c');
3198        let connection = Connection::open(&database).unwrap();
3199        connection
3200            .execute_batch(
3201                "CREATE TABLE strategy_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
3202                 INSERT INTO strategy_meta(key, value) VALUES ('version', '1');
3203                 CREATE TABLE strategy_definitions(
3204                   definition_id TEXT NOT NULL,
3205                   revision_digest TEXT PRIMARY KEY,
3206                   semantics_digest TEXT NOT NULL,
3207                   name TEXT NOT NULL,
3208                   version TEXT NOT NULL,
3209                   workflow_json TEXT NOT NULL,
3210                   asset_count INTEGER NOT NULL,
3211                   imported_at INTEGER NOT NULL
3212                 );
3213                 CREATE TABLE strategy_runs(
3214                   run_id TEXT PRIMARY KEY,
3215                   revision_digest TEXT NOT NULL REFERENCES strategy_definitions(revision_digest),
3216                   semantics_digest TEXT NOT NULL,
3217                   idempotency_key TEXT NOT NULL UNIQUE,
3218                   request_digest TEXT NOT NULL,
3219                   snapshot_json TEXT NOT NULL,
3220                   created_at INTEGER NOT NULL,
3221                   updated_at INTEGER NOT NULL
3222                 );",
3223            )
3224            .unwrap();
3225        let workflow_json = serde_json::to_string(&workflow()).unwrap();
3226        for (definition_id, revision, name) in [
3227            (
3228                "licoup-basic",
3229                retired_id_digest.clone(),
3230                "LicoUp Basic Strategy",
3231            ),
3232            (
3233                "other-id",
3234                retired_name_digest.clone(),
3235                "LicoUp Basic Strategy",
3236            ),
3237            ("imported-graph", kept_digest.clone(), "Imported Graph"),
3238        ] {
3239            connection
3240                .execute(
3241                    "INSERT INTO strategy_definitions(
3242                       definition_id, revision_digest, semantics_digest, name, version,
3243                       workflow_json, asset_count, imported_at
3244                     ) VALUES (?1, ?2, ?3, ?4, '1', ?5, 1, 1)",
3245                    params![definition_id, revision, digest('d'), name, workflow_json],
3246                )
3247                .unwrap();
3248        }
3249        let snapshot_json = serde_json::to_string(&RunSnapshot::empty(
3250            "run-retired",
3251            &retired_id_digest,
3252            "semantics",
3253        ))
3254        .unwrap();
3255        connection
3256            .execute(
3257                "INSERT INTO strategy_runs(
3258                   run_id, revision_digest, semantics_digest, idempotency_key,
3259                   request_digest, snapshot_json, created_at, updated_at
3260                 ) VALUES ('run-retired', ?1, 'semantics', 'key-retired', 'request', ?2, 1, 1)",
3261                params![retired_id_digest, snapshot_json],
3262            )
3263            .unwrap();
3264        drop(connection);
3265        let revisions = root
3266            .join("client-state")
3267            .join("adaptive-flywheel")
3268            .join("strategy-packages")
3269            .join("revisions");
3270        let retired_tree = revisions.join(&retired_id_digest);
3271        fs::create_dir_all(retired_tree.join("content")).unwrap();
3272        fs::write(retired_tree.join("content").join("marker.txt"), "retired").unwrap();
3273        let kept_tree = revisions.join(&kept_digest);
3274        fs::create_dir_all(kept_tree.join("content")).unwrap();
3275        fs::write(kept_tree.join("content").join("marker.txt"), "kept").unwrap();
3276
3277        assert!(
3278            StrategyStore::open(&root).is_err(),
3279            "a legacy schema must pass through the migration path first"
3280        );
3281        StrategyStore::open_for_migration(&root).unwrap();
3282
3283        let store = StrategyStore::open(&root).unwrap();
3284        let listed = store.list_definitions().unwrap();
3285        assert_eq!(listed.len(), 1);
3286        assert_eq!(listed[0].definition_id, "imported-graph");
3287        assert_eq!(listed[0].name, "Imported Graph");
3288        assert_eq!(listed[0].revision_digest, kept_digest);
3289        assert!(store.definition_by_revision(&retired_id_digest).is_err());
3290        assert!(store.definition_by_revision(&retired_name_digest).is_err());
3291        assert!(!retired_tree.exists());
3292        assert!(kept_tree.join("content").join("marker.txt").exists());
3293        remove_directory_tree(&root);
3294    }
3295}