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
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! The evaluator's own interface types — plan 02 §2a, §2d, §2f.
//!
//! Three of these (`Event`, `Decision`, `SessionState`) are **not** in the wire
//! schemas: they are the boundary between the caller and the engine, not
//! something that crosses the repo boundary, so they are hand-written here while
//! every wire type comes from [`crate::generated::types`] (the committed typify
//! output). `Verdict` in particular is frozen to four values in schemas 2.0 and
//! is used from there — never redefined.
//!
//! # The serialisation rule that outranks taste
//!
//! **A field the conformance corpus shows as `null` serialises as `null`.** There
//! is no blanket `skip_serializing_if` anywhere in this file, and adding one is a
//! corpus failure, not a style improvement: `schemas/conformance/*.jsonl` is
//! compared field by field with no stripping step, which is the whole reason
//! provenance (`engine_version`, `bundle_digest`) rides the response envelope
//! instead of sitting inside [`Decision`].
//!
//! Field order matches the corpus rows so a hand-diff of one line reads
//! straight down.

use serde::{Deserialize, Serialize};

use crate::generated::types::{Dimension, EffectVerb, Lever, PolicyMode, TargetClass, Verdict};

/// The composed mode that actually enforces.
pub const MODE_ENFORCE: &str = "enforce";
/// The composed mode that only records — a monitor artifact never joins.
pub const MODE_MONITOR: &str = "monitor";

// ── The Event ────────────────────────────────────────────────────────

/// One action, as the evaluator sees it — PRD §Atom language and the Event model.
///
/// Deliberately **tolerant**: no `deny_unknown_fields`, every field defaulted.
/// The deployed client is routinely older than the platform, and a row carrying
/// a key this build does not know must evaluate, not fail.
///
/// `now_ms` is not a field here. It is an argument to
/// [`evaluate`](super::evaluate), injected by the caller, because a clock read
/// inside the engine makes every replay of the same row non-reproducible.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Event {
    /// `HookEvent`: `pre_tool_use`, `post_tool_use`, `post_tool_batch`,
    /// `message_display`, `session_start`, `stop`. An open string — an event
    /// type the engine does not evaluate contributes nothing and is not an error.
    #[serde(default)]
    pub event_type: String,
    /// The `subject` session state is keyed by, upstream of this function.
    #[serde(default)]
    pub session_id: String,
    /// The harness's id for this tool call.
    #[serde(default)]
    pub tool_use_id: String,
    /// `mcp__<server>__<tool>` for MCP tools.
    #[serde(default)]
    pub tool_name: String,
    /// Verbatim from the harness. Opaque: the engine reads it, never rewrites it.
    #[serde(default)]
    pub tool_input: serde_json::Value,
    /// Post events only; `null` on a `pre_tool_use`.
    #[serde(default)]
    pub tool_result: Option<serde_json::Value>,
    #[serde(default)]
    pub agent: Option<AgentContext>,
    /// Which adapter decided. Held, never read by a leaf — the field vocabulary
    /// is closed and carries no `binding.*` member. Typed as a raw value because
    /// the corpus ships `{kind, mode}` while the PRD's `Binding` is an open
    /// string: the corpus wins, and neither shape is something evaluation reads.
    #[serde(default)]
    pub binding: serde_json::Value,
    /// The host paths the path → `TargetClass` resolver needs, supplied by the
    /// daemon. **With no `env`, the `system_path` and `workspace_file` rows do
    /// not fire and an absolute path no other row places resolves to unknown —
    /// never a guessed `workspace_file`.**
    #[serde(default)]
    pub env: Option<EventEnv>,
    /// Daemon-maintained session facts, for the `session.*` field family.
    #[serde(default)]
    pub session: Option<SessionFacts>,
    /// Post events only (R7). `micro_usd` exists only when a `pricebook` fact
    /// ships — the client never invents money.
    #[serde(default)]
    pub spend_delta: Option<SpendDelta>,
}

/// `agent.*` leaves read this. Every field optional: an adapter that cannot
/// supply one supplies none, and a leaf over an absent field is false.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct AgentContext {
    #[serde(default)]
    pub agent_id: Option<String>,
    #[serde(default)]
    pub agent_row_id: Option<String>,
    #[serde(default)]
    pub agent_type: Option<String>,
    #[serde(default)]
    pub environment: Option<String>,
    #[serde(default)]
    pub function: Option<String>,
    /// From the execution context where the adapter supplies one.
    #[serde(default)]
    pub principal: Option<String>,
}

/// Host paths, declared by the caller. The path classifier reads **these strings
/// only** — it never walks a directory tree, which is guarantee G4.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct EventEnv {
    #[serde(default)]
    pub home: Option<String>,
    #[serde(default)]
    pub cwd: Option<String>,
    #[serde(default)]
    pub path_dirs: Vec<String>,
    #[serde(default)]
    pub additional_dirs: Vec<String>,
}

/// `session.{elapsed_ms,tool_calls,spend_micro_usd,tokens}`.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SessionFacts {
    #[serde(default)]
    pub elapsed_ms: Option<i64>,
    #[serde(default)]
    pub tool_calls: Option<i64>,
    #[serde(default)]
    pub spend_micro_usd: Option<i64>,
    #[serde(default)]
    pub tokens: Option<i64>,
}

/// What the last call cost, integer only, read by `ADD_SAT` on a post event.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SpendDelta {
    #[serde(default)]
    pub tokens: Option<i64>,
    #[serde(default)]
    pub micro_usd: Option<i64>,
}

// ── The Decision ─────────────────────────────────────────────────────

/// What the engine answers — PRD §Atom language, and the corpus's `expected.decision`.
///
/// The PRD's prose names fourteen fields; the corpus carries eighteen. The four
/// it adds — `unknown`, `anomalies`, `ground_key`, `warnings` — are real and the
/// corpus is the authority on the serialised shape.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Decision {
    pub verdict: Verdict,
    pub artifact_id: Option<String>,
    pub atom_id: Option<String>,
    pub policy_public_id: Option<String>,
    pub dimension: Option<Dimension>,
    /// The **composed** mode of the artifact that decided — `monitor` for every
    /// artifact when `enforcement_enabled` is false.
    pub mode: Option<PolicyMode>,
    /// 1, 2 or 3. An integer ordinal, not a vocabulary.
    pub tier: Option<i64>,
    /// Shown to the developer verbatim. `""` when nothing decided.
    pub reason: String,
    /// What a monitor-mode artifact would have said. Monitor never joins.
    pub would_have_verdict: Option<Verdict>,
    /// Sorted, deduplicated fact ids that resolved to ⊥ — the gap made visible
    /// rather than silently resolved.
    pub inconclusive_facts: Vec<String>,
    /// R10: exactly one rewrite per action, owned by the first optimize
    /// contribution in artifact order.
    pub rewrite: Option<Rewrite>,
    pub hold: Option<HoldRequest>,
    /// The classifier's confident tuples.
    pub effects: Vec<Effect>,
    /// True when **no** artifact contributed. Distinct from `verdict: allow`,
    /// which an artifact may have decided on purpose.
    pub undecided: bool,
    /// D-15: the classifier's coverage gaps, carried ALONGSIDE `effects`, never
    /// in place of them. One unmapped sibling must not erase a confident tuple.
    pub unknown: Vec<UnknownCommand>,
    /// Where the Tier 2 `ANOMALY` op's codes land.
    pub anomalies: Vec<Anomaly>,
    /// The matched exception's ground key, so the corpus proves client/platform
    /// parity rather than asserting it.
    pub ground_key: Option<String>,
    /// Prose for a human — the D-17 fact-id registry warns here. Printed, never
    /// compared: a second implementation is free to word it differently.
    pub warnings: Vec<String>,
}

impl Default for Decision {
    /// The neutral answer: allow, undecided, nothing decided it.
    fn default() -> Self {
        Self {
            verdict: Verdict::Allow,
            artifact_id: None,
            atom_id: None,
            policy_public_id: None,
            dimension: None,
            mode: None,
            tier: None,
            reason: String::new(),
            would_have_verdict: None,
            inconclusive_facts: Vec::new(),
            rewrite: None,
            hold: None,
            effects: Vec::new(),
            undecided: true,
            unknown: Vec::new(),
            anomalies: Vec::new(),
            ground_key: None,
            warnings: Vec::new(),
        }
    }
}

/// One classified effect tuple. An action may carry several; a leaf matches if
/// **any** tuple matches.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Effect {
    pub verb: EffectVerb,
    pub target_class: TargetClass,
    /// `effect.attrs.<name>` reads these — `is_production`, `force`, `branch`,
    /// `program`. Always serialised, `{}` when empty.
    #[serde(default)]
    pub attrs: serde_json::Map<String, serde_json::Value>,
}

/// A simple command the classifier could not place, and under which of the 12
/// `unknown → ASK` shapes (PRD §Client evaluator).
///
/// `reason` is prose and is **not** compared across implementations; the runner
/// compares coverage gaps by `shape`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnknownCommand {
    /// 1–12, the shape from the PRD's table.
    pub shape: i64,
    pub reason: String,
    /// The simple command as written, or `""` when there is nothing to quote.
    pub command: String,
}

/// A Tier 2 `ANOMALY` op's output. An anomaly records; it never decides.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Anomaly {
    pub code: String,
    pub artifact_id: Option<String>,
    pub atom_id: Option<String>,
}

/// The single OPTIMIZE rewrite an action may carry (R10).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Rewrite {
    pub lever: Lever,
    pub artifact_id: Option<String>,
    /// Required when `lever` is `steer` — the instruction the model receives.
    pub steer_instruction: Option<String>,
}

/// The hold this action would open. **The trigger decision only**: the queue,
/// the deadline and the long poll belong to the daemon, outside this pure
/// function. Every field is optional because the `t3_hold` body's are.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HoldRequest {
    /// `ask_human` holds for a console answer; `reinforcement` puts the
    /// directive in front of the model and reads its reply.
    pub resolve: Option<String>,
    pub directive_template_id: Option<String>,
    pub max_attempts: Option<i64>,
    pub timeout_s: Option<i64>,
    pub on_timeout: Option<Verdict>,
    pub verdict_on_approve: Option<Verdict>,
    pub verdict_on_reject: Option<Verdict>,
}

// ── Session state ────────────────────────────────────────────────────

/// How much per-session state the bundle's Tier 2 programs need, unioned across
/// every program at **bundle load** — plan 02 §2d.
///
/// The engine never allocates per event: the caller owns the storage and its
/// capacity is computed from this, so the memory bound sits somewhere a reader
/// can see it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct StateLayout {
    /// Saturating counters.
    pub c: usize,
    /// Flag bits.
    pub f: usize,
    /// Timestamp slots.
    pub t: usize,
    /// Saturating amount accumulators (tokens, micro-USD).
    pub a: usize,
    /// Whether any program tracks a run of same-shape actions.
    pub run: bool,
}

/// The Tier 2 register file for one session — plan 02 §2d.
///
/// Field order matches the corpus's `state_out`. A state whose array lengths
/// disagree with the bundle's layout is **malformed, never padded**: two
/// implementations cannot agree about which counter is which if one of them
/// silently invents a slot.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SessionState {
    #[serde(default)]
    pub c: Vec<i64>,
    #[serde(default)]
    pub f: Vec<bool>,
    #[serde(default)]
    pub t: Vec<i64>,
    #[serde(default)]
    pub a: Vec<i64>,
    #[serde(default)]
    pub run: Option<RunState>,
}

impl SessionState {
    /// The zeroed state for a layout — what an evicted session comes back as.
    pub fn blank(layout: &StateLayout) -> Self {
        Self {
            c: vec![0; layout.c],
            f: vec![false; layout.f],
            t: vec![0; layout.t],
            a: vec![0; layout.a],
            run: layout.run.then(RunState::default),
        }
    }

    /// Whether this state's shape matches `layout`. The caller rejects on false;
    /// nothing in here pads.
    pub fn matches(&self, layout: &StateLayout) -> bool {
        self.c.len() == layout.c
            && self.f.len() == layout.f
            && self.t.len() == layout.t
            && self.a.len() == layout.a
            && self.run.is_some() == layout.run
    }
}

/// A run of same-shape actions. **Action shape** is
/// `sha256(tool_name ‖ first effect tuple ‖ command.program or "")` — argument
/// values are ignored, so `Read a.py` and `Read b.py` share a shape. Distinct
/// from the `loop_stop` lever's "identical", which keys on
/// `(tool, normalised input, result hash)`. Two different identicals; never merge them.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct RunState {
    /// `null` before the first tracked action.
    pub shape: Option<String>,
    pub len: i64,
}

// ── Bundle-load reporting ────────────────────────────────────────────

/// One artifact the two-stage parse did not activate.
///
/// **The engine does not log.** It has no tracing dependency and OL-12xx codes
/// live in `src/core/error.rs`; it returns these and the caller writes one line
/// per item. Without them an authoring UI shows a policy author a green light on
/// a rule that is not loaded.
///
/// The three keys are operational fields in `docs/evaluate-protocol.md`.
/// `reason` is an open string: a consumer never switches exhaustively on it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkippedItem {
    /// The artifact's `kind`, or `""` when it carried none.
    pub kind: String,
    /// The artifact's `artifact_id`, or `""` when it carried none.
    pub id: String,
    pub reason: String,
}

/// An artifact whose `kind` the evaluator cannot read. Skipped, never fatal.
pub const SKIP_UNKNOWN_KIND: &str = "unknown_kind";
/// The stage-2 per-`kind` body parse failed.
pub const SKIP_BODY_PARSE_ERROR: &str = "body_parse_error";
/// A `regex_lite` pattern `regex-syntax` rejected, or one over the size limit.
pub const SKIP_BAD_PATTERN: &str = "bad_pattern";
/// `min_client_version` above the bundle capability this engine implements.
pub const SKIP_BELOW_FLOOR: &str = "below_floor";
/// A non-integer number anywhere in the body. The bundle carries integers only.
pub const SKIP_FLOAT_PRESENT: &str = "float_present";

// ── Classification ───────────────────────────────────────────────────

/// What the effect classifier answers — plan 02 §2f, D-15.
///
/// **Not** `Result<Vec<Effect>, Unknown>`. `rm -rf /data/x && $UNKNOWN` yields
/// the confident `delete × data_store` **and** an unknown entry; the result type
/// that erases the first is how one unmapped sibling silently disarmed
/// enforcement in the spike (vault issue #34, amendment 7).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Classification {
    /// Effects we are confident about. May be non-empty even when `unknown` is.
    ///
    /// The oracle calls this field `tuples` (`tools/zone-eval-ref/effect.py`).
    /// Same contents; the name is noted so a reader adjudicating a disagreement
    /// between the two implementations can line them up.
    pub effects: Vec<Effect>,
    /// Which simple commands we could not classify, and under which shape.
    pub unknown: Vec<UnknownCommand>,
    /// Every simple command of the chain, after wrappers and env runners are
    /// stripped. Source of `command.program`, `command.argv` and
    /// `command.simple[]`.
    pub simple: Vec<SimpleCommand>,
    /// Every path the action names, with the class the resolver gave it. Source
    /// of `path.class` and `path.value`.
    ///
    /// **Not interchangeable with [`Effect::target_class`].** A tuple says
    /// `read × workspace_file`; it does not say *which* path produced it, and
    /// `path.value` has no other home.
    pub paths: Vec<ClassifiedPath>,
    /// Every URL the action names. Source of `url.host`, `url.tld`,
    /// `url.scheme` and `url.boundary`.
    pub urls: Vec<ClassifiedUrl>,
}

/// One simple command of a chain, after wrappers and env runners are stripped.
///
/// `program` and `argv` are what the field vocabulary reads. The other three are
/// what the **classifier** needs to see the `unknown → ASK` shapes, and dropping
/// them would make several of the twelve undetectable:
///
/// - `raw` — shape 1 measures the command's length, and shape 12's quote-escape
///   only exists in the text the AST split apart.
/// - `redirects` — shape 10 asks whether a redirection target has a `~`, a glob
///   or an unresolved variable, and the `cp`/`mv`/`tee` row of the shell table
///   reads `>` and `>>` to emit `write × dst`.
/// - `raw_argv` — `argv` with the quotes still on, element for element. Shapes 3
///   and 10 ask *"was this argument a literal?"*, and unquoting has already
///   thrown that answer away. Reading the operand out of the ORIGINAL token list
///   instead is what breaks behind every wrapper: `env bash -c "$X"` strips one
///   token, the index stops lining up, and the shape disappears — a coverage gap
///   silently turned into a confident `execute × shell`.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SimpleCommand {
    pub program: String,
    /// Unquoted, element for element with [`SimpleCommand::raw_argv`].
    pub argv: Vec<String>,
    /// The command as written, before splitting.
    pub raw: String,
    /// `(operator, target)` — `>`, `>>`, `2>`, `<`, `<<`, `<<<` and their targets.
    pub redirects: Vec<(String, String)>,
    /// `argv` with the quotes still on. See the type's own doc comment.
    pub raw_argv: Vec<String>,
}

impl SimpleCommand {
    /// What `command.simple[]` exposes to a leaf: `{program, argv}` and nothing
    /// else.
    ///
    /// The projection lives here, in one place, so the client and the oracle
    /// cannot disagree about what that field member *is* — the oracle's
    /// `SimpleCommand.as_dict()` is the same two keys.
    pub fn as_value(&self) -> serde_json::Value {
        serde_json::json!({ "program": self.program, "argv": self.argv })
    }
}

/// A path the action names, and the class the resolver gave it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClassifiedPath {
    /// First match wins over the ordered resolver table.
    pub class: TargetClass,
    /// The path as the action declared it — a **declared string**, never the
    /// result of walking the filesystem.
    pub value: String,
}

/// A URL the action names, decomposed into the `url.*` field family.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClassifiedUrl {
    /// The URL as written.
    pub value: String,
    /// Lowercased, with userinfo and port removed; an IPv6 literal stays
    /// bracketed, as the `url` crate reports it.
    pub host: String,
    /// The **registrable public suffix** from `psl`, so `example.co.uk` yields
    /// `co.uk`. The oracle has no PSL and yields the last label (`uk`) — a known,
    /// documented divergence, which is why `tld_in` corpus rows are written
    /// against single-label TLDs.
    pub tld: String,
    pub scheme: String,
    pub boundary: UrlBoundary,
}

/// Which side of the network boundary a host sits on.
///
/// A closed two-valued classification (PRD §Field vocabulary,
/// `url.boundary: internal|external`), so it is an enum rather than a string: an
/// atom gated on `url.boundary == internal` must not be defeatable by a typo on
/// the producing side.
///
/// **`internal` is arithmetic, not a string prefix.** RFC 1918's `172.16.0.0/12`
/// is `172.16.` through `172.31.` — sixteen blocks. A prefix list gets thirteen
/// of them wrong, and every one of those errors calls an internal host
/// `external`, which is the direction that silently stops an atom matching.
/// `localhost`, `::1`, a bare hostname with no dot, the `.local` / `.internal` /
/// `.lan` / `.home.arpa` / `.localdomain` suffixes, and the `10.` / `127.` /
/// `192.168.` / `169.254.` prefixes are the rest of `internal`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UrlBoundary {
    Internal,
    External,
}

impl UrlBoundary {
    /// The wire spelling, for a leaf comparing against a literal.
    pub fn as_str(self) -> &'static str {
        match self {
            UrlBoundary::Internal => "internal",
            UrlBoundary::External => "external",
        }
    }
}

// ── Per-artifact contributions ───────────────────────────────────────

/// What one artifact contributed, before exceptions and before the join.
///
/// Shared by `tier1`, `tier2`, `tier3`, `exception` and `join`, which is why it
/// lives here rather than in whichever of them happened to need it first.
#[derive(Debug, Clone, PartialEq)]
pub struct Contribution {
    pub artifact_id: Option<String>,
    pub atom_id: Option<String>,
    pub policy_public_id: Option<String>,
    pub dimension: Option<Dimension>,
    /// The **composed** mode: `monitor` when the artifact says so, and for every
    /// artifact when `enforcement_enabled` is false.
    pub mode: PolicyMode,
    pub tier: Option<i64>,
    pub verdict: Verdict,
    pub reason: String,
    /// Fact ids this artifact resolved to ⊥.
    pub inconclusive: Vec<String>,
    /// Anomaly codes this artifact raised.
    pub anomalies: Vec<String>,
    pub hold: Option<HoldRequest>,
    /// Set by `exception::apply` when a selector matched, so the decision can
    /// report the ground the answer was filed under.
    pub exception_ground_key: Option<String>,
    /// Set when the artifact fires as OPTIMIZE; `join` folds the first one in
    /// artifact order into [`Decision::rewrite`].
    pub lever: Option<Lever>,
    pub steer_instruction: Option<String>,
}

impl Contribution {
    /// Whether this contribution enters the join. **Monitor never joins** — it
    /// contributes `would_have_verdict` and an anomaly, and nothing else.
    pub fn is_enforcing(&self) -> bool {
        self.mode.as_str() == MODE_ENFORCE
    }
}

// ── The shared evaluation context ────────────────────────────────────

/// Everything the tiers read about one action, plus the two accumulators they
/// write into.
///
/// It carries no clock and no handle: `now_ms` is a value that arrived in the
/// frame. Facts and the classification are computed once per action and read by
/// every artifact, which is what keeps 2,000 atoms inside the p99 budget.
#[derive(Debug)]
pub struct EvalContext<'a> {
    pub event: &'a Event,
    pub classification: &'a Classification,
    pub facts: &'a super::facts::FactSet,
    /// Injected. Never `SystemTime`.
    pub now_ms: i64,
    /// Fact ids this artifact resolved to ⊥, deduplicated on insert.
    pub inconclusive: Vec<String>,
    /// Prose for a human; never compared across implementations.
    pub warnings: Vec<String>,
}

impl<'a> EvalContext<'a> {
    pub fn new(
        event: &'a Event,
        classification: &'a Classification,
        facts: &'a super::facts::FactSet,
        now_ms: i64,
    ) -> Self {
        Self {
            event,
            classification,
            facts,
            now_ms,
            inconclusive: Vec::new(),
            warnings: Vec::new(),
        }
    }

    /// A context for ONE artifact, so the ⊥ facts it notes are its own.
    ///
    /// The alternative — one shared list, recorded into and sliced back off when
    /// the tree turns out not to need it — is undo-by-slice, and it leaked in
    /// the oracle: deduplication meant a fact one artifact had already noted
    /// went missing from the next artifact's list.
    pub fn fork(&self) -> EvalContext<'a> {
        EvalContext::new(self.event, self.classification, self.facts, self.now_ms)
    }

    /// Record a ⊥ fact. Deduplicated — an atom that reads the same fact twice
    /// reports it once.
    pub fn note_inconclusive(&mut self, fact_id: &str) {
        if !self.inconclusive.iter().any(|f| f == fact_id) {
            self.inconclusive.push(fact_id.to_string());
        }
    }

    /// Record prose for a human. Deduplicated for the same reason.
    pub fn warn(&mut self, message: impl Into<String>) {
        let message = message.into();
        if !self.warnings.contains(&message) {
            self.warnings.push(message);
        }
    }

    /// Fold a forked context's warnings back. Warnings are about the BUNDLE, not
    /// about whether one artifact's tree happened to come out false, so they
    /// come back out of every artifact whatever it contributed.
    pub fn merge_warnings(&mut self, child: &EvalContext<'_>) {
        for warning in &child.warnings {
            self.warn(warning.clone());
        }
    }
}