openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! The Autonomy Zone evaluator — one engine, compiled into this client, and
//! nothing else.
//!
//! > The platform turns intent into rules. The client runs those rules against
//! > real traffic. **No agent action ever passes through the platform, and no
//! > verdict the platform computes is ever enforced.**
//!
//! When a design question comes up that this module does not answer, answer it
//! with that sentence.
//!
//! # A module, not a crate (D8), and the reasoning is not reversible
//!
//! This repository and its crate stay public and `exclude` does not cover
//! `src/`, so a separately published crate would buy no confidentiality — and it
//! would buy a **consumable shared library**, the exact object the architecture
//! is removing. Publishing it would not even be optional: the registry refuses a
//! crate whose path dependency is absent from it. So the engine lives here,
//! gated by the existing `policy` feature so the hook build cannot pull it in,
//! and the compile-time purity a crate boundary would have proved is bought by
//! `ci/check-engine-purity.py` instead — which also catches the clock and
//! randomness calls a dependency graph never sees.
//!
//! # Purity, in one sentence
//!
//! **The process may cache what is derived from its inputs; it may never retain
//! what is derived from its history.** A parsed bundle keyed by content digest is
//! fine. A session counter never is.
//!
//! No clock (`now_ms` arrives in the frame), no filesystem, no network, no
//! randomness, no environment, no global state. The shared scan automata are
//! built at bundle load and carried in a VALUE — inside [`Bundle`] — never in a
//! `OnceLock`.
//!
//! # Module map
//!
//! | Module | What it owns |
//! | ------ | ------------ |
//! | [`types`] | `Event`, `Decision`, `SessionState` and the engine's own vocabulary |
//! | [`bundle`] | the two-stage parse |
//! | [`kleene`] | strong-Kleene three-valued logic |
//! | [`facts`] | resolution, staleness, closed/open world, the fact-id registry |
//! | [`tier1`] | the shared scan table and the predicate trees |
//! | [`tier2`] | the register interpreter |
//! | [`tier3`] | the `t3_hold` trigger decision |
//! | [`effect`] | the classifier: built-in tools, shell AST, paths, the MCP feed |
//! | [`exception`] | `AskScope` selector matching |
//! | [`join`] | the verdict lattice and record assembly |

pub mod bundle;
pub mod effect;
pub mod exception;
pub mod facts;
pub mod join;
pub mod kleene;
pub mod tier1;
pub mod tier2;
pub mod tier3;
pub mod types;

use crate::generated::types::PolicyMode;
pub use bundle::{load, ArtifactBody, Bundle, BundleError, LoadedArtifact};
pub use kleene::{Inconclusive, Kleene};
pub use types::{
    AgentContext, Anomaly, Classification, Contribution, Decision, Effect, EvalContext, Event,
    EventEnv, HoldRequest, Rewrite, RunState, SessionFacts, SessionState, SkippedItem, SpendDelta,
    StateLayout, UnknownCommand,
};

/// The evaluator's **own** semantic version — not the client's, and not the
/// bundle capability level.
///
/// It moves only when **evaluation semantics** move: a different verdict for
/// the same `(bundle, event, state, now_ms)`. It is optional diagnostic
/// provenance for consumers, not a runtime compatibility or startup gate.
///
/// Do not sync it with `Cargo.toml`: a CLI fix need not change semantics.
/// The engine is a module, not a crate, which is exactly why
/// this constant is hand-maintained — `.claude/rules/zone-eval.md` governs when
/// it moves.
pub const ENGINE_VERSION: &str = "1.0.0";

/// The `openlatch evaluate` frame protocol version.
///
/// Emitted as diagnostic metadata; consumers validate the operational frames
/// documented in `docs/evaluate-protocol.md`, without a startup assertion.
/// Prefer additive evolution; incompatible framing is a deliberate contract change.
pub const PROTOCOL_VERSION: u32 = 1;

/// Evaluate one event against one bundle.
///
/// **This signature is frozen. Do not add parameters.** No path, no handle, no
/// async, nothing that reads a file — everything that reads bytes lives in the
/// command layer above and hands the bytes down, so a reviewer confirms the
/// statelessness contract from the signature alone. `now_ms` is injected; a call
/// to "now" in here would make every replay of the same row non-reproducible.
///
/// `state` is `Some` when the session's registers are available and **`None` when
/// they are not** — an evicted session, or a caller that never had them. That
/// distinction is load-bearing and is not the same as a blank state: a Tier 2
/// artifact whose registers are gone routes through its `on_evict`, which can
/// answer `reinit` (count from zero), `unknown` (undecidable, defer to
/// `on_inconclusive`) or `fail_static` (keep the artifact's verdict). Collapsing
/// `None` into [`SessionState::blank`] makes all three answer `reinit`, and
/// `fail_static` silently becomes an allow — a fail-open in the one place
/// AGENTS.md says there must never be one.
///
/// A `Some` state must match `bundle.state_layout` — [`tier2::validate_state`] is
/// how a caller checks, and a mismatch is **malformed, never padded**.
///
/// The order of business is the PRD's:
///
/// ```text
/// facts -> classify effects -> Tier 1 / Tier 2 / Tier 3 per artifact
///       -> exceptions -> join (Block > Ask > Optimize > Allow) -> Decision
/// ```
pub fn evaluate(
    bundle: &Bundle,
    event: &Event,
    state: Option<&SessionState>,
    now_ms: i64,
) -> (Decision, SessionState) {
    let facts = facts::resolve(bundle, now_ms);
    let classification = effect::classify(event, &facts, bundle.effect_classes.as_ref());
    let mut ctx = EvalContext::new(event, &classification, &facts, now_ms);

    // D-17, the DECLARING side: a bundle shipping `aproved_domains` resolves
    // every leaf reading `approved_domains` to ⊥ absent and would otherwise say
    // nothing at all. The referencing side is warned as each leaf is read.
    for fact_id in facts.declared_ids() {
        if !facts::is_known_fact_id(&fact_id) {
            ctx.warn(format!(
                "fact_id '{fact_id}' is not a known FactId \
                 (schemas/enums.schema.json $defs/FactId x-known-values)"
            ));
        }
    }

    // ONE automaton pass per EVENT, not per artifact. Every pattern in every
    // surviving artifact is already in a single `ScanTable`; running it once here
    // and handing the result down is what removes the remaining artifact-count
    // factor from the p99 budget — the budget tier 1 is shaped the way it is for.
    //
    // It is a local, and it is derived from this event: a bundle-held or
    // module-scope cache of it would be state derived from the process's
    // HISTORY, which the purity gate's global-state rule forbids and is right to.
    let scan = bundle.scan.scan(&ctx);

    // `None` is answered per artifact, through `on_evict`, so it cannot be
    // flattened here. What the DECISION carries back is still a state: `reinit`
    // is the default and counts from zero, so a blank file is the right thing to
    // hand the caller either way.
    let evicted = state.is_none();
    let mut working = match state {
        Some(state) => state.clone(),
        None => SessionState::blank(&bundle.state_layout),
    };
    let mut contributions: Vec<Contribution> = Vec::new();

    for artifact in &bundle.artifacts {
        // The org-wide kill switch, composed ONCE, above all three tiers.
        //
        // `tier{1,2,3}::contribution` are handed an artifact and a context, never
        // the bundle, so none of them can see `enforcement_enabled` — and the
        // switch has to reach the tier BEFORE it resolves, not after: PRD §Frozen
        // enums says *Monitor always `allow_and_flag`*, so flipping an enforce
        // artifact to monitor also changes what an inconclusive tree contributes.
        // Overwriting `Contribution::mode` afterwards would set the right mode and
        // leave the wrong `would_have_verdict`.
        //
        // The clone is paid only when the switch is OFF — the path on which
        // nothing enforces — and never on the enforcing path.
        let monitored;
        let artifact = if bundle.enforcement_enabled {
            artifact
        } else {
            monitored = as_monitor(artifact);
            &monitored
        };
        let contribution = match &artifact.body {
            ArtifactBody::T1(body) => {
                tier1::contribution_with(artifact, body, &mut ctx, &bundle.scan, &scan)
            }
            ArtifactBody::T2(body) => {
                if evicted {
                    // `evicted_contribution` answers the two `on_evict` values
                    // that decide without running the program: `unknown` (the
                    // shape is undecidable, defer to `on_inconclusive`) and
                    // `fail_static` (keep the artifact's verdict).
                    //
                    // `None` therefore means `reinit` — the DEFAULT — and reinit
                    // is not "contributes nothing": it counts from zero, so the
                    // program still runs, against the blank register file
                    // `working` is already holding. The oracle does the same
                    // thing in one function (`evaluate.py::_tier2_contribution`
                    // falls through to `blank_state` after the two early
                    // returns); split in two here, the fall-through is this
                    // `or_else`. Dropping it would turn every `reinit` row into
                    // a silent no-op.
                    tier2::evicted_contribution(
                        artifact,
                        body,
                        tier2::declared_mode(artifact),
                        &bundle.state_layout,
                    )
                    .or_else(|| {
                        tier2::contribution(
                            artifact,
                            body,
                            &mut ctx,
                            &bundle.scan,
                            &mut working,
                            &bundle.state_layout,
                        )
                    })
                } else {
                    tier2::contribution(
                        artifact,
                        body,
                        &mut ctx,
                        &bundle.scan,
                        &mut working,
                        &bundle.state_layout,
                    )
                }
            }
            ArtifactBody::T3(body) => tier3::contribution(artifact, body, &mut ctx, &bundle.scan),
            // Exceptions are held apart by the loader and applied below; one
            // never contributes a verdict of its own.
            ArtifactBody::Exception(_) => None,
        };
        if let Some(contribution) = contribution {
            contributions.push(contribution);
        }
    }

    exception::apply(
        &mut contributions,
        &bundle.exceptions,
        &ctx,
        bundle
            .meta
            .as_ref()
            .and_then(|meta| meta.effective_zone.as_ref()),
    );

    let decision = join::assemble(&contributions, &ctx);
    (decision, working)
}

/// The artifact as the tiers must see it when `enforcement_enabled` is false.
///
/// The kill switch composes every artifact as `monitor` regardless of its own
/// mode, exactly as the schema-1 rule mode does. `tier2::declared_mode` then
/// reads `monitor` off the envelope and `tier2::on_inconclusive_verdict` forces
/// `allow_and_flag` from it, so one field carries the whole composition and no
/// tier needs a second argument.
fn as_monitor(artifact: &LoadedArtifact) -> LoadedArtifact {
    let mut artifact = artifact.clone();
    artifact.envelope.mode = Some(PolicyMode(types::MODE_MONITOR.to_string()));
    artifact
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::generated::types::Verdict;

    fn empty_bundle() -> Bundle {
        load(serde_json::json!({
            "schema_version": 2,
            "organization_id": "org",
            "revision": 1,
            "built_at": "2026-09-01T00:00:00Z",
            "enforcement_enabled": true,
            "signature": null,
            "artifacts": [],
        }))
        .expect("the envelope parses")
    }

    #[test]
    fn an_empty_bundle_allows_and_reports_undecided() {
        let bundle = empty_bundle();
        let event = Event::default();
        let state = SessionState::blank(&bundle.state_layout);
        let (decision, state_out) = evaluate(&bundle, &event, Some(&state), 1_756_742_400_000);
        assert_eq!(decision.verdict, Verdict::Allow);
        assert!(decision.undecided, "nothing decided it");
        assert_eq!(state_out, state, "a stateless bundle advances no state");
    }

    #[test]
    fn evaluation_is_deterministic_for_the_same_inputs() {
        let bundle = empty_bundle();
        let event = Event::default();
        let state = SessionState::blank(&bundle.state_layout);
        let first = evaluate(&bundle, &event, Some(&state), 1_756_742_400_000);
        let second = evaluate(&bundle, &event, Some(&state), 1_756_742_400_000);
        assert_eq!(first, second);
    }

    #[test]
    fn an_unknown_fact_id_warns_and_still_evaluates() {
        let bundle = load(serde_json::json!({
            "schema_version": 2,
            "organization_id": "org",
            "revision": 1,
            "built_at": "2026-09-01T00:00:00Z",
            "enforcement_enabled": true,
            "signature": null,
            "artifacts": [],
            "facts": [{"fact_id": "change_ticket", "kind": "set", "value": []}],
        }))
        .expect("the envelope parses");
        let event = Event::default();
        let state = SessionState::blank(&bundle.state_layout);
        let (decision, _) = evaluate(&bundle, &event, Some(&state), 1_756_742_400_000);
        assert_eq!(
            decision.warnings.len(),
            1,
            "the typo is named, not swallowed"
        );
        assert_eq!(decision.verdict, Verdict::Allow, "and it still evaluates");
    }

    #[test]
    fn an_evicted_session_is_not_a_blank_one_at_the_boundary() {
        // The channel, pinned. `None` and `Some(blank)` must be able to produce
        // different answers, because corpus rows tier2-on-evict-{reinit,unknown,
        // fail_static} all arrive as `state_in: null` and expect allow, ask and
        // block respectively. Flattening `None` to a blank state makes two of
        // the three unreachable at ANY implementation of tier2.rs — and the one
        // that turns into an allow is `fail_static`.
        let bundle = empty_bundle();
        let event = Event::default();
        let blank = SessionState::blank(&bundle.state_layout);
        let (_, from_none) = evaluate(&bundle, &event, None, 1_756_742_400_000);
        let (_, from_blank) = evaluate(&bundle, &event, Some(&blank), 1_756_742_400_000);
        // With no Tier 2 artifact the two agree on the state they hand back —
        // `reinit` is the default. What matters is that the engine can still
        // TELL them apart, which the signature is what guarantees.
        assert_eq!(from_none, from_blank);
        assert_eq!(from_none, blank);
    }

    /// A one-artifact bundle, so the four things the entry point composes are
    /// testable without a corpus fixture.
    fn bundle_of(enforcement_enabled: bool, artifact: serde_json::Value) -> Bundle {
        load(serde_json::json!({
            "schema_version": 2,
            "organization_id": "org",
            "revision": 1,
            "built_at": "2026-09-01T00:00:00Z",
            "enforcement_enabled": enforcement_enabled,
            "signature": null,
            "artifacts": [artifact],
        }))
        .expect("the envelope parses")
    }

    /// A Tier 1 artifact whose leaf reads a fact the bundle does not ship, so the
    /// tree comes out ⊥ and the artifact takes its `on_inconclusive` branch.
    fn inconclusive_artifact(on_inconclusive: &str) -> serde_json::Value {
        serde_json::json!({
            "artifact_id": "probe",
            "atom_id": "atom-probe",
            "mode": "enforce",
            "tier": 1,
            "kind": "t1_predicate_tree",
            "on_inconclusive": on_inconclusive,
            "body": {
                "node": {
                    "op": "leaf",
                    "leaf": {"pred": "fact", "fact": {
                        "fact_id": "change_ticket", "op": "equals", "value": true
                    }},
                },
                "verdict": "block",
                "reason": "probe fired",
            },
        })
    }

    #[test]
    fn switching_enforcement_off_changes_what_every_artifact_contributes() {
        // Reported independently by two slices: no tier sees the bundle, so
        // nothing composed the org-wide kill switch and switching enforcement
        // off changed NOTHING. `07-join-enforcement-off` is the corpus row.
        let artifact = inconclusive_artifact("block");
        let event = Event::default();
        let now = 1_756_742_400_000;

        let on = bundle_of(true, artifact.clone());
        let (decision, _) = evaluate(&on, &event, None, now);
        assert_eq!(decision.verdict, Verdict::Block, "enforce blocks");
        assert_eq!(decision.mode.map(|m| m.0), Some("enforce".to_string()));

        let off = bundle_of(false, artifact);
        let (decision, _) = evaluate(&off, &event, None, now);
        assert_eq!(decision.verdict, Verdict::Allow, "monitor never joins");
        assert_eq!(decision.artifact_id, None);
        assert!(
            !decision.undecided,
            "it contributed, it just did not decide"
        );
        // And the composition reaches the tier, not just the record: PRD §Frozen
        // enums, *Monitor always `allow_and_flag`*. A post-hoc mode overwrite
        // would report `block` here — the artifact's own `on_inconclusive`,
        // resolved before anything knew the switch was off.
        assert_eq!(
            decision.would_have_verdict,
            Some(Verdict::Allow),
            "the kill switch reaches on_inconclusive, not only the mode field"
        );
    }

    #[test]
    fn an_evicted_fail_static_artifact_blocks_rather_than_failing_open() {
        // AGENTS.md's first invariant. Until the entry point called
        // `evicted_contribution`, `on_evict: fail_static` ALLOWED where it must
        // BLOCK — a silent fail-open, with no test and no log line.
        let bundle = bundle_of(
            true,
            serde_json::json!({
                "artifact_id": "prog",
                "atom_id": "atom-prog",
                "mode": "enforce",
                "tier": 2,
                "kind": "t2_register_program",
                "body": {
                    "state_layout": {"c": 1, "f": 0, "t": 0, "a": 0, "run": false},
                    "pre": [],
                    "post": [],
                    "on_evict": "fail_static",
                    "verdict": "block",
                    "reason": "the registers are gone and this rule keeps enforcing",
                },
            }),
        );
        let event = Event::default();
        let (decision, _) = evaluate(&bundle, &event, None, 1_756_742_400_000);
        assert_eq!(decision.verdict, Verdict::Block);
        assert_eq!(decision.artifact_id.as_deref(), Some("prog"));

        // And the same artifact with the registers present runs its program
        // instead, which contributes nothing here.
        let state = SessionState::blank(&bundle.state_layout);
        let (decision, _) = evaluate(&bundle, &event, Some(&state), 1_756_742_400_000);
        assert!(decision.undecided, "a present state runs the program");
    }

    #[test]
    fn the_scan_is_computed_once_per_event_and_changes_no_answer() {
        // The seam S5 shipped. Sharing one `ScanResult` across every artifact is
        // a performance change ONLY: two artifacts carrying the same keyword must
        // answer exactly as they did when each scanned for itself.
        let keyword = |id: &str| {
            serde_json::json!({
                "artifact_id": id,
                "atom_id": format!("atom-{id}"),
                "mode": "enforce",
                "tier": 1,
                "kind": "t1_predicate_tree",
                "body": {
                    "node": {"op": "leaf", "leaf": {
                        "pred": "keyword", "field": "input.strings", "value": ["curl"]
                    }},
                    "verdict": "block",
                    "reason": format!("{id} fired"),
                },
            })
        };
        let bundle = load(serde_json::json!({
            "schema_version": 2,
            "organization_id": "org",
            "revision": 1,
            "built_at": "2026-09-01T00:00:00Z",
            "enforcement_enabled": true,
            "signature": null,
            "artifacts": [keyword("a"), keyword("b")],
        }))
        .expect("the envelope parses");
        let event = Event {
            tool_name: "Bash".to_string(),
            tool_input: serde_json::json!({"command": "curl https://example.com"}),
            ..Default::default()
        };
        let (decision, _) = evaluate(&bundle, &event, None, 1_756_742_400_000);
        assert_eq!(decision.verdict, Verdict::Block);
        assert_eq!(
            decision.artifact_id.as_deref(),
            Some("a"),
            "both fired; the tie breaks on artifact_id"
        );
    }

    #[test]
    fn the_engine_version_is_not_the_crate_version() {
        assert_ne!(
            ENGINE_VERSION,
            env!("CARGO_PKG_VERSION"),
            "engine semantics and the client release have independent version lifecycles"
        );
    }
}