Skip to main content

licoup_workflow_store/
controlled.rs

1//! Durable implementation of the T07.4a controlled-store port.
2
3use anyhow::{Result, ensure};
4use rusqlite::{OptionalExtension, TransactionBehavior, params};
5
6use crate::control::{
7    AdmissionConflict, AdmissionFences, AdmissionReceipt, ControlOperation, ControlScope,
8    ControlledStore, FenceFacts, OperationGrant, evaluate_fences,
9};
10use crate::routing::{ChannelKind, QueueBounds};
11
12use super::store::StrategyStore;
13use super::{queue, queue::QueueStoreError};
14
15#[derive(Clone, Debug)]
16pub struct DurableControlledStore {
17    store: StrategyStore,
18}
19
20impl DurableControlledStore {
21    pub fn from_store(store: StrategyStore) -> Self {
22        Self { store }
23    }
24
25    pub fn open(portable_root: &std::path::Path) -> Result<Self> {
26        Ok(Self::from_store(StrategyStore::open(portable_root)?))
27    }
28
29    pub fn store(&self) -> &StrategyStore {
30        &self.store
31    }
32
33    pub fn set_graph_revision(&self, graph_id: &str, revision: u64) -> Result<()> {
34        validate_id(graph_id)?;
35        self.store.with_connection(|connection| -> Result<()> {
36            connection.execute(
37                "INSERT INTO workflow_graph_state(graph_id, graph_revision, barrier_active)
38                 VALUES (?1, ?2, 0)
39                 ON CONFLICT(graph_id) DO UPDATE SET graph_revision=excluded.graph_revision",
40                params![graph_id, revision as i64],
41            )?;
42            Ok(())
43        })
44    }
45
46    pub fn set_node_control_revision(
47        &self,
48        graph_id: &str,
49        node_id: &str,
50        revision: u64,
51    ) -> Result<()> {
52        validate_id(graph_id)?;
53        validate_id(node_id)?;
54        self.store.with_connection(|connection| -> Result<()> {
55            connection.execute(
56                "INSERT INTO workflow_node_state(
57                   graph_id, node_id, control_revision, generation, lifecycle_state,
58                   pause_negotiating
59                 ) VALUES (?1, ?2, ?3, 1, 'ready', 0)
60                 ON CONFLICT(graph_id, node_id)
61                 DO UPDATE SET control_revision=excluded.control_revision",
62                params![graph_id, node_id, revision as i64],
63            )?;
64            Ok(())
65        })
66    }
67
68    pub fn mark_invocation_settled(
69        &self,
70        graph_id: &str,
71        node_id: &str,
72        invocation_id: &str,
73    ) -> Result<()> {
74        validate_id(graph_id)?;
75        validate_id(node_id)?;
76        validate_id(invocation_id)?;
77        self.store.with_connection(|connection| -> Result<()> {
78            connection.execute(
79                "INSERT INTO workflow_invocations(graph_id, node_id, invocation_id, settled)
80                 VALUES (?1, ?2, ?3, 1)
81                 ON CONFLICT(graph_id, node_id, invocation_id) DO UPDATE SET settled=1",
82                params![graph_id, node_id, invocation_id],
83            )?;
84            Ok(())
85        })
86    }
87
88    pub fn mark_stop_requested(&self, graph_id: &str, target: &str) -> Result<()> {
89        validate_id(graph_id)?;
90        validate_id(target)?;
91        self.store.with_connection(|connection| -> Result<()> {
92            connection.execute(
93                "INSERT OR IGNORE INTO workflow_stop_requests(graph_id, target)
94                 VALUES (?1, ?2)",
95                params![graph_id, target],
96            )?;
97            Ok(())
98        })
99    }
100
101    pub fn mark_pause_negotiating(&self, graph_id: &str, target: &str) -> Result<()> {
102        validate_id(graph_id)?;
103        validate_id(target)?;
104        self.store.with_connection(|connection| -> Result<()> {
105            connection.execute(
106                "INSERT INTO workflow_pause_requests(graph_id, target, active)
107                 VALUES (?1, ?2, 1)
108                 ON CONFLICT(graph_id, target) DO UPDATE SET active=1",
109                params![graph_id, target],
110            )?;
111            Ok(())
112        })
113    }
114
115    pub fn clear_pause_negotiating(&self, graph_id: &str, target: &str) -> Result<bool> {
116        validate_id(graph_id)?;
117        validate_id(target)?;
118        self.store.with_connection(|connection| {
119            Ok(connection.execute(
120                "UPDATE workflow_pause_requests SET active=0
121                 WHERE graph_id=?1 AND target=?2 AND active=1",
122                params![graph_id, target],
123            )? == 1)
124        })
125    }
126
127    pub fn set_target_generation(
128        &self,
129        graph_id: &str,
130        target: &str,
131        generation: u64,
132    ) -> Result<()> {
133        validate_id(graph_id)?;
134        validate_id(target)?;
135        self.store.with_connection(|connection| -> Result<()> {
136            connection.execute(
137                "INSERT INTO workflow_node_state(
138                   graph_id, node_id, control_revision, generation, lifecycle_state,
139                   pause_negotiating
140                 ) VALUES (?1, ?2, 0, ?3, 'ready', 0)
141                 ON CONFLICT(graph_id, node_id)
142                 DO UPDATE SET generation=excluded.generation",
143                params![graph_id, target, generation as i64],
144            )?;
145            Ok(())
146        })
147    }
148
149    pub fn set_graph_barrier(&self, graph_id: &str, active: bool) -> Result<()> {
150        validate_id(graph_id)?;
151        self.store.with_connection(|connection| -> Result<()> {
152            connection.execute(
153                "INSERT INTO workflow_graph_state(graph_id, graph_revision, barrier_active)
154                 VALUES (?1, 1, ?2)
155                 ON CONFLICT(graph_id) DO UPDATE SET barrier_active=excluded.barrier_active",
156                params![graph_id, i64::from(active)],
157            )?;
158            Ok(())
159        })
160    }
161
162    pub fn revoke_grant(&self, principal_id: &str, grant: &OperationGrant) -> Result<()> {
163        validate_id(principal_id)?;
164        self.store.with_connection(|connection| {
165            connection.execute(
166                "INSERT OR IGNORE INTO workflow_revoked_grants(principal_id, grant_json)
167                 VALUES (?1, ?2)",
168                params![principal_id, serde_json::to_string(grant)?],
169            )?;
170            Ok(())
171        })
172    }
173}
174
175fn storage_conflict(error: anyhow::Error) -> AdmissionConflict {
176    AdmissionConflict::Storage {
177        reason: error.to_string(),
178    }
179}
180
181impl ControlledStore for DurableControlledStore {
182    fn get_graph_revision(&self, graph_id: &str) -> Result<Option<u64>, AdmissionConflict> {
183        self.store
184            .with_connection(|connection| -> Result<Option<u64>> {
185                Ok(connection
186                    .query_row(
187                        "SELECT graph_revision FROM workflow_graph_state WHERE graph_id=?1",
188                        params![graph_id],
189                        |row| row.get::<_, i64>(0),
190                    )
191                    .optional()?
192                    .map(|value| value.max(0) as u64))
193            })
194            .map_err(storage_conflict)
195    }
196
197    fn get_node_control_revision(
198        &self,
199        graph_id: &str,
200        node_id: &str,
201    ) -> Result<Option<u64>, AdmissionConflict> {
202        self.store
203            .with_connection(|connection| -> Result<Option<u64>> {
204                Ok(connection
205                    .query_row(
206                        "SELECT control_revision FROM workflow_node_state
207                         WHERE graph_id=?1 AND node_id=?2",
208                        params![graph_id, node_id],
209                        |row| row.get::<_, i64>(0),
210                    )
211                    .optional()?
212                    .map(|value| value.max(0) as u64))
213            })
214            .map_err(storage_conflict)
215    }
216
217    fn is_invocation_settled(
218        &self,
219        graph_id: &str,
220        node_id: &str,
221        invocation_id: &str,
222    ) -> Result<bool, AdmissionConflict> {
223        self.store
224            .with_connection(|connection| -> Result<bool> {
225                let count: i64 = connection.query_row(
226                    "SELECT COUNT(*) FROM workflow_invocations
227                     WHERE graph_id=?1 AND node_id=?2 AND invocation_id=?3 AND settled=1",
228                    params![graph_id, node_id, invocation_id],
229                    |row| row.get(0),
230                )?;
231                Ok(count != 0)
232            })
233            .map_err(storage_conflict)
234    }
235
236    fn is_stop_requested(&self, graph_id: &str, target: &str) -> Result<bool, AdmissionConflict> {
237        self.store
238            .with_connection(|connection| -> Result<bool> {
239                let count: i64 = connection.query_row(
240                    "SELECT COUNT(*) FROM workflow_stop_requests
241                     WHERE graph_id=?1 AND target IN (?2, 'graph')",
242                    params![graph_id, target],
243                    |row| row.get(0),
244                )?;
245                Ok(count != 0)
246            })
247            .map_err(storage_conflict)
248    }
249
250    fn is_pause_negotiating(
251        &self,
252        graph_id: &str,
253        target: &str,
254    ) -> Result<bool, AdmissionConflict> {
255        self.store
256            .with_connection(|connection| -> Result<bool> {
257                let count: i64 = connection.query_row(
258                    "SELECT COUNT(*) FROM workflow_pause_requests
259                     WHERE graph_id=?1 AND target=?2 AND active=1",
260                    params![graph_id, target],
261                    |row| row.get(0),
262                )?;
263                Ok(count != 0)
264            })
265            .map_err(storage_conflict)
266    }
267
268    fn is_graph_barrier_active(&self, graph_id: &str) -> Result<bool, AdmissionConflict> {
269        self.store
270            .with_connection(|connection| -> Result<bool> {
271                Ok(connection
272                    .query_row(
273                        "SELECT barrier_active FROM workflow_graph_state WHERE graph_id=?1",
274                        params![graph_id],
275                        |row| row.get::<_, i64>(0),
276                    )
277                    .optional()?
278                    .is_some_and(|value| value == 1))
279            })
280            .map_err(storage_conflict)
281    }
282
283    fn get_target_generation(
284        &self,
285        graph_id: &str,
286        target: &str,
287    ) -> Result<Option<u64>, AdmissionConflict> {
288        self.store
289            .with_connection(|connection| -> Result<Option<u64>> {
290                Ok(connection
291                    .query_row(
292                        "SELECT generation FROM workflow_node_state
293                         WHERE graph_id=?1 AND node_id=?2",
294                        params![graph_id, target],
295                        |row| row.get::<_, i64>(0),
296                    )
297                    .optional()?
298                    .map(|value| value.max(0) as u64))
299            })
300            .map_err(storage_conflict)
301    }
302
303    fn is_grant_revoked(
304        &self,
305        principal_id: &str,
306        grant: &OperationGrant,
307    ) -> Result<bool, AdmissionConflict> {
308        self.store
309            .with_connection(|connection| -> Result<bool> {
310                let grant_json = serde_json::to_string(grant)?;
311                let count: i64 = connection.query_row(
312                    "SELECT COUNT(*) FROM workflow_revoked_grants
313                     WHERE principal_id=?1 AND grant_json=?2",
314                    params![principal_id, grant_json],
315                    |row| row.get(0),
316                )?;
317                Ok(count != 0)
318            })
319            .map_err(storage_conflict)
320    }
321
322    fn find_idempotency_record(
323        &self,
324        request_id: &str,
325    ) -> Result<Option<(AdmissionReceipt, String)>, AdmissionConflict> {
326        self.store
327            .with_connection(|connection| -> Result<Option<(AdmissionReceipt, String)>> {
328                let value: Option<(String, String)> = connection
329                    .query_row(
330                        "SELECT receipt_json, payload_digest FROM workflow_control_admissions
331                         WHERE request_id=?1",
332                        params![request_id],
333                        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
334                    )
335                    .optional()?;
336                value
337                    .map(|(receipt, digest)| Ok((serde_json::from_str(&receipt)?, digest)))
338                    .transpose()
339            })
340            .map_err(storage_conflict)
341    }
342
343    fn record_admission(
344        &mut self,
345        receipt: &mut AdmissionReceipt,
346        payload_digest: String,
347        expected_control_revision: Option<u64>,
348        fences: &AdmissionFences,
349    ) -> Result<(), AdmissionConflict> {
350        self.store.with_connection_typed(
351            |connection| -> std::result::Result<(), AdmissionConflict> {
352                let transaction = connection
353                    .transaction_with_behavior(TransactionBehavior::Immediate)
354                    .map_err(AdmissionConflict::from)?;
355                // Idempotency is re-read under the write lock: two identical
356                // requests admitted back-to-back converge on the recorded
357                // receipt instead of tripping the unique constraint.
358                let existing: Option<(String, String)> = transaction
359                    .query_row(
360                        "SELECT receipt_json, payload_digest FROM workflow_control_admissions
361                         WHERE request_id=?1",
362                        params![receipt.request_id],
363                        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
364                    )
365                    .optional()?;
366                if let Some((receipt_json, existing_digest)) = existing {
367                    if existing_digest == payload_digest {
368                        *receipt = serde_json::from_str(&receipt_json)?;
369                        receipt.is_replay = true;
370                        transaction.commit()?;
371                        return Ok(());
372                    }
373                    let existing_receipt: AdmissionReceipt =
374                        serde_json::from_str(&receipt_json)?;
375                    return Err(AdmissionConflict::IdempotencyMismatch {
376                        request_id: receipt.request_id.clone(),
377                        existing_author: existing_receipt.author_principal_id,
378                        reason: "request_id reused with different command payload".to_string(),
379                    });
380                }
381                // Every admission fence is re-evaluated under the write lock
382                // that commits this admission: a revocation, stop, pause,
383                // barrier or generation change committed between the proxy's
384                // prechecks and now still rejects the operation.
385                let mut covering_grants_all_revoked = !fences.covering_grants.is_empty();
386                for grant in &fences.covering_grants {
387                    let count: i64 = transaction.query_row(
388                        "SELECT COUNT(*) FROM workflow_revoked_grants
389                         WHERE principal_id=?1 AND grant_json=?2",
390                        params![fences.principal_id, serde_json::to_string(grant)?],
391                        |row| row.get(0),
392                    )?;
393                    if count == 0 {
394                        covering_grants_all_revoked = false;
395                        break;
396                    }
397                }
398                let barrier_active: i64 = transaction
399                    .query_row(
400                        "SELECT barrier_active FROM workflow_graph_state WHERE graph_id=?1",
401                        params![receipt.graph_id],
402                        |row| row.get(0),
403                    )
404                    .optional()?
405                    .unwrap_or(0);
406                let stop_hit = |target: &str| -> Result<bool, rusqlite::Error> {
407                    let count: i64 = transaction.query_row(
408                        "SELECT COUNT(*) FROM workflow_stop_requests
409                         WHERE graph_id=?1 AND target IN (?2, 'graph')",
410                        params![receipt.graph_id, target],
411                        |row| row.get(0),
412                    )?;
413                    Ok(count != 0)
414                };
415                let stopped_scope = if stop_hit(&fences.target_scope)? {
416                    Some(fences.target_scope.clone())
417                } else {
418                    match &fences.invocation_scope {
419                        Some(key) if stop_hit(key)? => Some(key.clone()),
420                        _ => None,
421                    }
422                };
423                let invocation_settled = match (&receipt.operation, &receipt.target_node_id) {
424                    (ControlOperation::Steer { invocation_id, .. }, Some(node)) => {
425                        let count: i64 = transaction.query_row(
426                            "SELECT COUNT(*) FROM workflow_invocations
427                             WHERE graph_id=?1 AND node_id=?2 AND invocation_id=?3 AND settled=1",
428                            params![receipt.graph_id, node, invocation_id],
429                            |row| row.get(0),
430                        )?;
431                        count != 0
432                    }
433                    _ => false,
434                };
435                let pause_negotiating: i64 = transaction.query_row(
436                    "SELECT COUNT(*) FROM workflow_pause_requests
437                     WHERE graph_id=?1 AND target=?2 AND active=1",
438                    params![receipt.graph_id, fences.target_scope],
439                    |row| row.get(0),
440                )?;
441                let graph_revision: i64 = transaction
442                    .query_row(
443                        "SELECT graph_revision FROM workflow_graph_state WHERE graph_id=?1",
444                        params![receipt.graph_id],
445                        |row| row.get(0),
446                    )
447                    .optional()?
448                    .unwrap_or(1);
449                let target_generation: i64 = transaction
450                    .query_row(
451                        "SELECT generation FROM workflow_node_state
452                         WHERE graph_id=?1 AND node_id=?2",
453                        params![receipt.graph_id, fences.target_scope],
454                        |row| row.get(0),
455                    )
456                    .optional()?
457                    .unwrap_or(1);
458                evaluate_fences(
459                    receipt,
460                    fences,
461                    FenceFacts {
462                        covering_grants_all_revoked,
463                        barrier_active: barrier_active == 1,
464                        stopped_scope,
465                        invocation_settled,
466                        pause_negotiating: pause_negotiating != 0,
467                        graph_revision: graph_revision.max(0) as u64,
468                        target_generation: target_generation.max(0) as u64,
469                    },
470                )?;
471                receipt.graph_revision = graph_revision.max(0) as u64;
472                // The exclusive-control fence is a compare-and-swap, not a
473                // pre-txn read: the persisted revision is re-read under the write
474                // lock, so two proxies that both observed the same revision cannot
475                // both admit.
476                if let Some(node) = receipt.target_node_id.as_deref() {
477                    let persisted: i64 = transaction
478                        .query_row(
479                            "SELECT control_revision FROM workflow_node_state
480                         WHERE graph_id=?1 AND node_id=?2",
481                            params![receipt.graph_id, node],
482                            |row| row.get(0),
483                        )
484                        .optional()?
485                        .unwrap_or(0);
486                    let persisted = persisted.max(0) as u64;
487                    if let Some(expected) = expected_control_revision
488                        && expected != persisted
489                    {
490                        return Err(AdmissionConflict::ControlRevisionConflict {
491                            target_node_id: node.to_string(),
492                            expected,
493                            current: persisted,
494                            reason: "competing exclusive control revision conflict".to_string(),
495                        });
496                    }
497                    receipt.control_revision = persisted + 1;
498                }
499                let next_sequence: i64 = transaction.query_row(
500                    "SELECT COALESCE(MAX(admitted_sequence), 0) + 1
501                 FROM workflow_control_admissions",
502                    [],
503                    |row| row.get(0),
504                )?;
505                receipt.admitted_sequence = next_sequence.max(0) as u64;
506                transaction.execute(
507                    "INSERT INTO workflow_control_admissions(
508                   request_id, admitted_sequence, receipt_json, payload_digest
509                 ) VALUES (?1, ?2, ?3, ?4)",
510                    params![
511                        receipt.request_id,
512                        receipt.admitted_sequence as i64,
513                        serde_json::to_string(receipt)?,
514                        payload_digest,
515                    ],
516                )?;
517                transaction.execute(
518                    "INSERT INTO workflow_graph_state(graph_id, graph_revision, barrier_active)
519                 VALUES (?1, ?2, 0)
520                 ON CONFLICT(graph_id) DO NOTHING",
521                    params![receipt.graph_id, receipt.graph_revision as i64],
522                )?;
523                if let Some(node) = receipt.target_node_id.as_deref() {
524                    transaction.execute(
525                        "INSERT INTO workflow_node_state(
526                       graph_id, node_id, control_revision, generation, lifecycle_state,
527                       pause_negotiating
528                     ) VALUES (?1, ?2, ?3, 1, 'ready', 0)
529                     ON CONFLICT(graph_id, node_id)
530                     DO UPDATE SET control_revision=excluded.control_revision",
531                        params![receipt.graph_id, node, receipt.control_revision as i64],
532                    )?;
533                }
534                match &receipt.operation {
535                    ControlOperation::Pause { scope } => {
536                        transaction.execute(
537                            "INSERT INTO workflow_pause_requests(graph_id, target, active)
538                         VALUES (?1, ?2, 1)
539                         ON CONFLICT(graph_id, target) DO UPDATE SET active=1",
540                            params![receipt.graph_id, pause_target(scope)],
541                        )?;
542                    }
543                    ControlOperation::Stop { scope, .. } => {
544                        let target = control_target(scope);
545                        transaction.execute(
546                            "INSERT OR IGNORE INTO workflow_stop_requests(graph_id, target)
547                         VALUES (?1, ?2)",
548                            params![receipt.graph_id, target],
549                        )?;
550                        if matches!(scope, ControlScope::Graph) {
551                            transaction.execute(
552                                "UPDATE workflow_graph_state SET barrier_active=1 WHERE graph_id=?1",
553                                params![receipt.graph_id],
554                            )?;
555                        }
556                    }
557                    _ => {}
558                }
559                let payload = serde_json::to_value(&*receipt)?;
560                queue::enqueue_in_transaction(
561                    &transaction,
562                    ChannelKind::Control,
563                    &receipt.request_id,
564                    &payload,
565                    receipt.admitted_at_unix_ms,
566                    QueueBounds::default(),
567                )
568                .map_err(|error| match error {
569                    QueueStoreError::Capacity(capacity) => {
570                        AdmissionConflict::QueueCapacity(capacity)
571                    }
572                    other => AdmissionConflict::Storage {
573                        reason: other.to_string(),
574                    },
575                })?;
576                transaction.commit()?;
577                Ok(())
578            },
579        )
580    }
581}
582
583pub(crate) fn initialize_schema(connection: &rusqlite::Connection) -> Result<()> {
584    connection.execute_batch(
585        "CREATE TABLE IF NOT EXISTS workflow_graph_state(
586           graph_id TEXT PRIMARY KEY,
587           graph_revision INTEGER NOT NULL,
588           barrier_active INTEGER NOT NULL
589         );
590         CREATE TABLE IF NOT EXISTS workflow_node_state(
591           graph_id TEXT NOT NULL,
592           node_id TEXT NOT NULL,
593           control_revision INTEGER NOT NULL,
594           generation INTEGER NOT NULL,
595           lifecycle_state TEXT NOT NULL,
596           pause_negotiating INTEGER NOT NULL,
597           PRIMARY KEY(graph_id, node_id)
598         );
599         CREATE TABLE IF NOT EXISTS workflow_invocations(
600           graph_id TEXT NOT NULL,
601           node_id TEXT NOT NULL,
602           invocation_id TEXT NOT NULL,
603           settled INTEGER NOT NULL,
604           PRIMARY KEY(graph_id, node_id, invocation_id)
605         );
606         CREATE TABLE IF NOT EXISTS workflow_stop_requests(
607           graph_id TEXT NOT NULL,
608           target TEXT NOT NULL,
609           PRIMARY KEY(graph_id, target)
610         );
611         CREATE TABLE IF NOT EXISTS workflow_pause_requests(
612           graph_id TEXT NOT NULL,
613           target TEXT NOT NULL,
614           active INTEGER NOT NULL,
615           PRIMARY KEY(graph_id, target)
616         );
617         CREATE TABLE IF NOT EXISTS workflow_revoked_grants(
618           principal_id TEXT NOT NULL,
619           grant_json TEXT NOT NULL,
620           PRIMARY KEY(principal_id, grant_json)
621         );
622         CREATE TABLE IF NOT EXISTS workflow_control_admissions(
623           request_id TEXT PRIMARY KEY,
624           admitted_sequence INTEGER NOT NULL UNIQUE,
625           receipt_json TEXT NOT NULL,
626           payload_digest TEXT NOT NULL
627         );",
628    )?;
629    Ok(())
630}
631
632fn control_target(scope: &ControlScope) -> String {
633    match scope {
634        ControlScope::Graph => "graph".to_owned(),
635        ControlScope::Node(node) => node.clone(),
636        ControlScope::Invocation {
637            node_id,
638            invocation_id,
639        } => format!("{node_id}/{invocation_id}"),
640    }
641}
642
643fn pause_target(scope: &ControlScope) -> String {
644    match scope {
645        ControlScope::Graph => "graph".to_owned(),
646        ControlScope::Node(node) => node.clone(),
647        ControlScope::Invocation { node_id, .. } => node_id.clone(),
648    }
649}
650
651fn validate_id(value: &str) -> Result<()> {
652    ensure!(
653        !value.trim().is_empty()
654            && value == value.trim()
655            && value.len() <= 160
656            && !value.chars().any(char::is_control),
657        "workflow_control_id_invalid"
658    );
659    Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::control::ControlledStore;
666    use std::fs;
667    use uuid::Uuid;
668
669    #[test]
670    fn controlled_state_and_admission_receipts_survive_restart() {
671        let root = std::env::temp_dir().join(format!("lico-workflow-control-{}", Uuid::new_v4()));
672        let grant = OperationGrant::GraphAll;
673        {
674            let store = StrategyStore::open(&root).unwrap();
675            let mut control = store.durable_control();
676            control.set_graph_revision("graph-1", 4).unwrap();
677            control
678                .set_node_control_revision("graph-1", "node-1", 9)
679                .unwrap();
680            control
681                .set_target_generation("graph-1", "node-1", 3)
682                .unwrap();
683            control
684                .mark_invocation_settled("graph-1", "node-1", "inv-1")
685                .unwrap();
686            control.mark_stop_requested("graph-1", "node-1").unwrap();
687            control.mark_pause_negotiating("graph-1", "node-1").unwrap();
688            control.revoke_grant("principal-1", &grant).unwrap();
689
690            let mut receipt = AdmissionReceipt {
691                admitted_sequence: 0,
692                request_id: "request-1".into(),
693                graph_id: "graph-1".into(),
694                target_node_id: Some("node-1".into()),
695                author_principal_id: "principal-1".into(),
696                admitted_at_unix_ms: 10,
697                operation: ControlOperation::Pause {
698                    scope: ControlScope::Invocation {
699                        node_id: "node-1".into(),
700                        invocation_id: "inv-1".into(),
701                    },
702                },
703                graph_revision: 4,
704                control_revision: 9,
705                is_replay: false,
706            };
707            control
708                .record_admission(
709                    &mut receipt,
710                    "digest".into(),
711                    Some(9),
712                    &AdmissionFences::default(),
713                )
714                .unwrap();
715            assert_eq!(receipt.admitted_sequence, 1);
716        }
717
718        let store = StrategyStore::open(&root).unwrap();
719        let control = store.durable_control();
720        let queued = store.durable_queue().replay_from_cursor(0).unwrap();
721        assert_eq!(queued.len(), 1);
722        assert_eq!(queued[0].channel, ChannelKind::Control);
723        assert_eq!(queued[0].item_id, "request-1");
724        assert_eq!(control.get_graph_revision("graph-1").unwrap(), Some(4));
725        assert_eq!(
726            control
727                .get_node_control_revision("graph-1", "node-1")
728                .unwrap(),
729            Some(10)
730        );
731        assert_eq!(
732            control.get_target_generation("graph-1", "node-1").unwrap(),
733            Some(3)
734        );
735        assert!(
736            control
737                .is_invocation_settled("graph-1", "node-1", "inv-1")
738                .unwrap()
739        );
740        assert!(control.is_stop_requested("graph-1", "node-1").unwrap());
741        assert!(control.is_pause_negotiating("graph-1", "node-1").unwrap());
742        assert!(control.is_grant_revoked("principal-1", &grant).unwrap());
743        assert_eq!(
744            control
745                .find_idempotency_record("request-1")
746                .unwrap()
747                .unwrap()
748                .0
749                .admitted_sequence,
750            1
751        );
752        assert!(
753            control
754                .clear_pause_negotiating("graph-1", "node-1")
755                .unwrap()
756        );
757        assert!(!control.is_pause_negotiating("graph-1", "node-1").unwrap());
758        drop(control);
759        drop(store);
760        let _ = fs::remove_dir_all(root);
761    }
762}