ctrlrun 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctrlrun/policy.py ADDED
@@ -0,0 +1,389 @@
1
+ """Policy loading and rule evaluation to a Decision. Build-list item 2; SPEC-v0.1 §3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import operator
7
+ import os
8
+ from collections.abc import Callable, Iterable, Mapping
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+ from types import MappingProxyType
13
+ from typing import Any, Final
14
+
15
+ import yaml
16
+
17
+ from .action import Action
18
+ from .errors import PolicyError
19
+
20
+ POLICY_SCHEMA: Final = "ctrlrun.policy/v1"
21
+ CONFIG_ENV_VAR: Final = "CTRLRUN_CONFIG"
22
+ DEFAULT_POLICY_FILENAME: Final = "ctrlrun.yaml"
23
+
24
+ #: Reasons attached to an Evaluation that no rule produced.
25
+ UNKNOWN_ACTION: Final = "unknown_action"
26
+ NO_MATCHING_RULE: Final = "no_matching_rule"
27
+ BARE_DECISION: Final = "decision"
28
+
29
+ _LOG = logging.getLogger(__name__)
30
+
31
+ _NUMERIC_COMPARE: Final[Mapping[str, Callable[[int, int], bool]]] = {
32
+ "lt": operator.lt,
33
+ "lte": operator.le,
34
+ "gt": operator.gt,
35
+ "gte": operator.ge,
36
+ }
37
+ _OPERATORS: Final = ("eq", "neq", "in", *_NUMERIC_COMPARE)
38
+ #: Longest first, so `amount_neq` reads as (amount, neq) and never as (amount_n, eq).
39
+ _OPERATORS_BY_LENGTH: Final = tuple(sorted(_OPERATORS, key=len, reverse=True))
40
+
41
+ _TOP_LEVEL_KEYS: Final = frozenset({"schema", "actions"})
42
+ _ENTRY_KEYS: Final = frozenset({"decision", "rules"})
43
+ _RULE_KEYS: Final = frozenset({"when", "decision"})
44
+
45
+ #: Names of `Action` fields (SPEC-v0.1 §2.1), which a condition cannot address: conditions
46
+ #: see the action's *arguments* and nothing else (§3.2). Writing one reads like it scopes a
47
+ #: rule — `when: { environment_eq: production }` — and matches nothing, so it is refused at
48
+ #: load. Same fail-closed reading as `{resource}` in §5.1: two candidate meanings for one
49
+ #: name, so make the author rename rather than silently pick one.
50
+ RESERVED_ARGUMENTS: Final = frozenset(
51
+ {"action_id", "agent", "environment", "principal", "resource", "user"}
52
+ )
53
+
54
+
55
+ class Decision(StrEnum):
56
+ """What may happen to an action: exactly three outcomes in v0.1 (SPEC-v0.1 §3.3).
57
+
58
+ `StrEnum`, so a member renders as its value in receipts and CLI output (SPEC-v0.1 §6.1).
59
+ """
60
+
61
+ ALLOW = "allow"
62
+ APPROVE = "approve"
63
+ DENY = "deny"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Evaluation:
68
+ """A decision and the reason it was reached, e.g. `rule[1]` or `unknown_action`."""
69
+
70
+ decision: Decision
71
+ reason: str
72
+
73
+
74
+ def _is_int(value: object) -> bool:
75
+ """True for a real int. `bool` subclasses int in Python; SPEC-v0.1 §3.2 excludes it."""
76
+ return isinstance(value, int) and not isinstance(value, bool)
77
+
78
+
79
+ def _type_name(value: object) -> str:
80
+ return type(value).__name__
81
+
82
+
83
+ def _equal(value: object, operand: object) -> bool:
84
+ """Type-strict equality: `True` never equals `1`, and a list never equals a scalar.
85
+
86
+ SPEC: §3.2 — equality is type-strict and applies recursively inside containers.
87
+ Canonical arguments distinguish bool from int (§2.3), so conditions must too, or a
88
+ policy written for `1` would match `True`.
89
+ """
90
+ if isinstance(value, bool) or isinstance(operand, bool):
91
+ return value is operand
92
+ if isinstance(value, Mapping) and isinstance(operand, Mapping):
93
+ return value.keys() == operand.keys() and all(
94
+ _equal(value[key], operand[key]) for key in value
95
+ )
96
+ if isinstance(value, list | tuple) and isinstance(operand, list | tuple):
97
+ return len(value) == len(operand) and all(
98
+ _equal(item, other) for item, other in zip(value, operand, strict=True)
99
+ )
100
+ if _is_container(value) or _is_container(operand):
101
+ return False
102
+ return bool(value == operand)
103
+
104
+
105
+ def _is_container(value: object) -> bool:
106
+ return isinstance(value, Mapping | list | tuple)
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class _Condition:
111
+ key: str
112
+ argument: str
113
+ op: str
114
+ operand: Any
115
+
116
+ def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool:
117
+ if self.argument not in arguments:
118
+ # SPEC §3.2 — still false, never an error, but never silent either. Defaults are
119
+ # applied when a call is bound (§8), so an argument is either always present or
120
+ # never: an absent one is a typo, and silence let a mistyped rule disappear into
121
+ # a catch-all below it.
122
+ _LOG.warning(
123
+ "%s: condition %s ignored: the action has no argument %r (it has: %s)",
124
+ action_name,
125
+ self.key,
126
+ self.argument,
127
+ ", ".join(sorted(arguments)) or "none",
128
+ )
129
+ return False
130
+ value = arguments[self.argument]
131
+ if self.op == "eq":
132
+ return _equal(value, self.operand)
133
+ if self.op == "neq":
134
+ return not _equal(value, self.operand)
135
+ if self.op == "in":
136
+ return any(_equal(value, item) for item in self.operand)
137
+ if not _is_int(value):
138
+ _LOG.warning(
139
+ "%s: condition %s ignored: argument %r is %s, not int",
140
+ action_name,
141
+ self.key,
142
+ self.argument,
143
+ _type_name(value),
144
+ )
145
+ return False
146
+ return _NUMERIC_COMPARE[self.op](value, self.operand)
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class _Rule:
151
+ decision: Decision
152
+ conditions: tuple[_Condition, ...]
153
+
154
+ def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool:
155
+ # A rule with no `when` has no conditions, so all() holds and it always matches.
156
+ return all(condition.matches(action_name, arguments) for condition in self.conditions)
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class _ActionPolicy:
161
+ decision: Decision | None
162
+ rules: tuple[_Rule, ...]
163
+
164
+ def evaluate(self, action_name: str, arguments: Mapping[str, Any]) -> Evaluation:
165
+ if self.decision is not None:
166
+ return Evaluation(self.decision, BARE_DECISION)
167
+ for index, rule in enumerate(self.rules):
168
+ if rule.matches(action_name, arguments):
169
+ return Evaluation(rule.decision, f"rule[{index}]")
170
+ return Evaluation(Decision.DENY, NO_MATCHING_RULE)
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class Policy:
175
+ """Action-level autonomy policy: which actions may run, and under which conditions.
176
+
177
+ Load with `Policy.from_file()`. A policy that cannot be loaded is an error, never an
178
+ empty permissive policy (SPEC-v0.1 §3.4).
179
+ """
180
+
181
+ actions: Mapping[str, _ActionPolicy]
182
+ source: str
183
+
184
+ @classmethod
185
+ def from_file(cls, path: str | os.PathLike[str] | None = None) -> Policy:
186
+ """Load a policy from `path`, else `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml`."""
187
+ resolved = Path(path) if path is not None else discover_policy_path()
188
+ try:
189
+ text = resolved.read_text(encoding="utf-8")
190
+ except OSError as exc:
191
+ raise PolicyError(f"policy file {resolved} could not be read: {exc}") from exc
192
+ return cls.from_yaml(text, source=str(resolved))
193
+
194
+ @classmethod
195
+ def from_yaml(cls, text: str, *, source: str = "<string>") -> Policy:
196
+ """Parse and validate a policy document. Anything malformed raises `PolicyError`."""
197
+ try:
198
+ document = yaml.safe_load(text)
199
+ except yaml.YAMLError as exc:
200
+ raise PolicyError(f"{source}: not valid YAML: {exc}") from exc
201
+ return cls._from_document(document, source)
202
+
203
+ @classmethod
204
+ def _from_document(cls, document: object, source: str) -> Policy:
205
+ if not isinstance(document, Mapping):
206
+ raise PolicyError(
207
+ f"{source}: policy must be a mapping with 'schema' and 'actions' keys, "
208
+ f"got {_type_name(document)}"
209
+ )
210
+ # SPEC: §3.4 — an absent `schema` key is an unknown schema, never "assume v1".
211
+ schema = document.get("schema")
212
+ if schema != POLICY_SCHEMA:
213
+ raise PolicyError(
214
+ f"{source}: unknown policy schema {schema!r}, expected {POLICY_SCHEMA!r}"
215
+ )
216
+ # SPEC: §3.1 — key sets are closed, so a typo such as `action:` fails at load
217
+ # instead of silently denying everything at runtime.
218
+ _reject_unknown_keys(document, _TOP_LEVEL_KEYS, f"{source}: top level")
219
+
220
+ entries = document.get("actions")
221
+ if not isinstance(entries, Mapping):
222
+ raise PolicyError(
223
+ f"{source}: 'actions' must be a mapping of action name to entry, "
224
+ f"got {_type_name(entries)}"
225
+ )
226
+ actions: dict[str, _ActionPolicy] = {}
227
+ for name, entry in entries.items():
228
+ if not isinstance(name, str) or not name:
229
+ raise PolicyError(f"{source}: action names must be non-empty strings, got {name!r}")
230
+ actions[name] = _parse_entry(entry, f"{source}: action {name!r}")
231
+ return cls(actions=MappingProxyType(actions), source=source)
232
+
233
+ def evaluate(self, action: Action) -> Evaluation:
234
+ """Decide an action. No side effects; an unlisted action is denied (SPEC-v0.1 §3.4)."""
235
+ entry = self.actions.get(action.name)
236
+ if entry is None:
237
+ return Evaluation(Decision.DENY, UNKNOWN_ACTION)
238
+ # Conditions see exactly what the executor will receive (SPEC-v0.1 §2.2), which is
239
+ # also why a list argument compares equal to a list operand.
240
+ return entry.evaluate(action.name, action.canonical_arguments)
241
+
242
+
243
+ def discover_policy_path() -> Path:
244
+ """The policy file this process would load: `$CTRLRUN_CONFIG`, else `./ctrlrun.yaml`."""
245
+ configured = os.environ.get(CONFIG_ENV_VAR)
246
+ if configured is None:
247
+ return Path.cwd() / DEFAULT_POLICY_FILENAME
248
+ if not configured.strip():
249
+ raise PolicyError(f"{CONFIG_ENV_VAR} is set but empty; unset it or point it at a policy")
250
+ return Path(configured)
251
+
252
+
253
+ def _reject_unknown_keys(mapping: Mapping[Any, Any], allowed: Iterable[str], where: str) -> None:
254
+ unknown = sorted(repr(key) for key in mapping if key not in set(allowed))
255
+ if unknown:
256
+ raise PolicyError(
257
+ f"{where}: unknown key(s) {', '.join(unknown)}; allowed: {', '.join(sorted(allowed))}"
258
+ )
259
+
260
+
261
+ def _parse_entry(entry: object, where: str) -> _ActionPolicy:
262
+ if not isinstance(entry, Mapping):
263
+ raise PolicyError(
264
+ f"{where}: entry must be a mapping with 'decision' or 'rules', got {_type_name(entry)}"
265
+ )
266
+ _reject_unknown_keys(entry, _ENTRY_KEYS, where)
267
+ has_decision = "decision" in entry
268
+ has_rules = "rules" in entry
269
+ if has_decision == has_rules:
270
+ raise PolicyError(f"{where}: entry must have exactly one of 'decision' or 'rules'")
271
+
272
+ if has_decision:
273
+ return _ActionPolicy(decision=_parse_decision(entry["decision"], where), rules=())
274
+
275
+ rules = entry["rules"]
276
+ if not isinstance(rules, list) or not rules:
277
+ raise PolicyError(f"{where}: 'rules' must be a non-empty list, got {_type_name(rules)}")
278
+ return _ActionPolicy(
279
+ decision=None,
280
+ rules=tuple(
281
+ _parse_rule(rule, f"{where} rule[{index}]") for index, rule in enumerate(rules)
282
+ ),
283
+ )
284
+
285
+
286
+ def _parse_decision(value: object, where: str) -> Decision:
287
+ allowed = ", ".join(member.value for member in Decision)
288
+ if not isinstance(value, str):
289
+ raise PolicyError(f"{where}: decision must be one of {allowed}, got {_type_name(value)}")
290
+ try:
291
+ return Decision(value)
292
+ except ValueError as exc:
293
+ raise PolicyError(
294
+ f"{where}: unknown decision {value!r}, expected one of {allowed}"
295
+ ) from exc
296
+
297
+
298
+ def _parse_rule(rule: object, where: str) -> _Rule:
299
+ if not isinstance(rule, Mapping):
300
+ raise PolicyError(f"{where}: a rule must be a mapping, got {_type_name(rule)}")
301
+ _reject_unknown_keys(rule, _RULE_KEYS, where)
302
+ if "decision" not in rule:
303
+ raise PolicyError(f"{where}: a rule must have a 'decision'")
304
+ decision = _parse_decision(rule["decision"], where)
305
+ if "when" not in rule:
306
+ return _Rule(decision=decision, conditions=())
307
+
308
+ when = rule["when"]
309
+ # SPEC: §3.2 — `when` is either absent (always matches) or a non-empty mapping. An
310
+ # empty mapping is a truncated edit, and the catch-all is already spelled "no `when`".
311
+ if not isinstance(when, Mapping) or not when:
312
+ raise PolicyError(
313
+ f"{where}: 'when' must be a non-empty mapping of conditions, got {_type_name(when)}"
314
+ )
315
+ return _Rule(
316
+ decision=decision,
317
+ conditions=tuple(_parse_condition(key, operand, where) for key, operand in when.items()),
318
+ )
319
+
320
+
321
+ def _parse_condition(key: object, operand: object, where: str) -> _Condition:
322
+ if not isinstance(key, str):
323
+ raise PolicyError(f"{where}: condition keys must be strings, got {key!r}")
324
+ argument, op = _split_condition_key(key, where)
325
+ return _Condition(
326
+ key=key,
327
+ argument=argument,
328
+ op=op,
329
+ operand=_parse_operand(op, operand, where, key),
330
+ )
331
+
332
+
333
+ def _split_condition_key(key: str, where: str) -> tuple[str, str]:
334
+ for op in _OPERATORS_BY_LENGTH:
335
+ suffix = f"_{op}"
336
+ if key.endswith(suffix) and len(key) > len(suffix):
337
+ argument = key[: -len(suffix)]
338
+ if argument in RESERVED_ARGUMENTS:
339
+ raise PolicyError(
340
+ f"{where}: condition {key!r} names the Action field {argument!r}, not an "
341
+ "argument; a v0.1 condition can only address the action's arguments, so "
342
+ "this rule would never match. If the protected function really does take "
343
+ f"an argument called {argument!r}, rename it."
344
+ )
345
+ return argument, op
346
+ raise PolicyError(
347
+ f"{where}: condition {key!r} must be '<argument>_<op>' where op is one of "
348
+ f"{', '.join(sorted(_OPERATORS))}"
349
+ )
350
+
351
+
352
+ def _parse_operand(op: str, operand: object, where: str, key: str) -> object:
353
+ if op in _NUMERIC_COMPARE:
354
+ if not _is_int(operand):
355
+ raise PolicyError(
356
+ f"{where}: condition {key!r}: a numeric operator needs an int operand, "
357
+ f"got {_type_name(operand)}"
358
+ )
359
+ return operand
360
+ if op == "in":
361
+ if not isinstance(operand, list):
362
+ raise PolicyError(
363
+ f"{where}: condition {key!r}: '_in' needs a list operand, got {_type_name(operand)}"
364
+ )
365
+ return tuple(_checked_operand(item, where, key) for item in operand)
366
+ return _checked_operand(operand, where, key)
367
+
368
+
369
+ def _checked_operand(operand: object, where: str, key: str) -> object:
370
+ """Validate an operand against the argument types allowed by SPEC-v0.1 §2.3."""
371
+ if isinstance(operand, float):
372
+ raise PolicyError(
373
+ f"{where}: condition {key!r}: float operands are not allowed; use integer minor "
374
+ "units (amount_lte: 50000) or a decimal string"
375
+ )
376
+ if operand is None or isinstance(operand, str | int): # bool is a subclass of int
377
+ return operand
378
+ if isinstance(operand, Mapping):
379
+ for name in operand:
380
+ if not isinstance(name, str):
381
+ raise PolicyError(
382
+ f"{where}: condition {key!r}: operand keys must be strings, got {name!r}"
383
+ )
384
+ return {name: _checked_operand(value, where, key) for name, value in operand.items()}
385
+ if isinstance(operand, list):
386
+ return [_checked_operand(item, where, key) for item in operand]
387
+ raise PolicyError(
388
+ f"{where}: condition {key!r}: {_type_name(operand)} is not an allowed operand type"
389
+ )
ctrlrun/py.typed ADDED
File without changes
ctrlrun/receipt.py ADDED
@@ -0,0 +1,229 @@
1
+ """Receipts and the event log. Build-list item 8; SPEC-v0.1 §6.
2
+
3
+ `Control` produces a `Receipt` for every action that reaches a terminal state, and an `Event`
4
+ for every step it takes. `EventLog` is where those land on disk: `.ctrlrun/receipts.jsonl`
5
+ and `.ctrlrun/events.jsonl`, one JSON object per line, in append order.
6
+
7
+ A receipt is evidence, and evidence has to outlive the tool that wrote it — so the file form
8
+ is plain JSON with enums rendered by value, readable by anything that can read a line.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import secrets
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from enum import StrEnum
20
+ from pathlib import Path
21
+ from typing import Any, Final
22
+
23
+ from .action import Principal
24
+ from .policy import Decision
25
+
26
+ RECEIPT_SCHEMA: Final = "ctrlrun.receipt/v1"
27
+
28
+ #: The two files of SPEC-v0.1 §6, written beside the state database.
29
+ RECEIPTS_FILENAME: Final = "receipts.jsonl"
30
+ EVENTS_FILENAME: Final = "events.jsonl"
31
+
32
+ _ID_HEX_BYTES: Final = 16 # "ctr_" + 32 hex chars
33
+
34
+
35
+ def new_receipt_id() -> str:
36
+ return f"ctr_{secrets.token_hex(_ID_HEX_BYTES)}"
37
+
38
+
39
+ def iso_timestamp(moment: datetime) -> str:
40
+ """UTC ISO-8601 with a `Z` suffix, as in SPEC-v0.1 §6.1."""
41
+ return moment.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
42
+
43
+
44
+ class ReceiptResult(StrEnum):
45
+ """The terminal outcome recorded on a receipt (SPEC-v0.1 §6.1).
46
+
47
+ `BLOCKED` covers duplicate, ambiguous-retry and approval-mismatch refusals.
48
+ """
49
+
50
+ COMMITTED = "committed"
51
+ FAILED = "failed"
52
+ AMBIGUOUS = "ambiguous"
53
+ DENIED = "denied"
54
+ BLOCKED = "blocked"
55
+
56
+
57
+ class EventType(StrEnum):
58
+ """The closed set of event types in SPEC-v0.1 §6.2."""
59
+
60
+ ACTION_PROPOSED = "ACTION_PROPOSED"
61
+ POLICY_EVALUATED = "POLICY_EVALUATED"
62
+ APPROVAL_REQUESTED = "APPROVAL_REQUESTED"
63
+ APPROVAL_GRANTED = "APPROVAL_GRANTED"
64
+ APPROVAL_DENIED = "APPROVAL_DENIED"
65
+ APPROVAL_EXPIRED = "APPROVAL_EXPIRED"
66
+ APPROVAL_INVALIDATED = "APPROVAL_INVALIDATED"
67
+ APPROVAL_CONSUMED = "APPROVAL_CONSUMED"
68
+ EFFECT_RESERVED = "EFFECT_RESERVED"
69
+ EFFECT_RESERVATION_REFUSED = "EFFECT_RESERVATION_REFUSED"
70
+ EXECUTION_STARTED = "EXECUTION_STARTED"
71
+ EXECUTION_COMMITTED = "EXECUTION_COMMITTED"
72
+ EXECUTION_FAILED = "EXECUTION_FAILED"
73
+ EXECUTION_AMBIGUOUS = "EXECUTION_AMBIGUOUS"
74
+ EFFECT_RESOLVED = "EFFECT_RESOLVED"
75
+ ACTION_DENIED = "ACTION_DENIED"
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Event:
80
+ """One ordered step in the life of an action (SPEC-v0.1 §6.2).
81
+
82
+ `event_id` is assigned by the StateStore on append, not by the caller.
83
+ """
84
+
85
+ type: EventType
86
+ action_id: str
87
+ ts: datetime
88
+ data: Mapping[str, Any] = field(default_factory=dict)
89
+ effect_key: str | None = None
90
+ approval_id: str | None = None
91
+ event_id: int | None = None
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ return {
95
+ "event_id": self.event_id,
96
+ "ts": iso_timestamp(self.ts),
97
+ "type": str(self.type),
98
+ "action_id": self.action_id,
99
+ "effect_key": self.effect_key,
100
+ "approval_id": self.approval_id,
101
+ "data": dict(self.data),
102
+ }
103
+
104
+ def to_json(self) -> str:
105
+ """One JSONL line. Enums render by value, for readers that never imported CTRLRun."""
106
+ return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Receipt:
111
+ """Portable evidence of one action that reached a terminal state (SPEC-v0.1 §6.1)."""
112
+
113
+ receipt_id: str
114
+ action_id: str
115
+ action: str
116
+ action_hash: str
117
+ principal: Principal
118
+ resource: str | None
119
+ arguments: Mapping[str, Any]
120
+ environment: str
121
+ decision: Decision
122
+ decision_reason: str
123
+ result: ReceiptResult
124
+ started_at: datetime
125
+ finished_at: datetime
126
+ approval_id: str | None = None
127
+ approver: str | None = None
128
+ effect_key: str | None = None
129
+ attempt: int = 1
130
+ error: str | None = None
131
+
132
+ def to_dict(self) -> dict[str, Any]:
133
+ """The receipt as plain JSON-serializable data, in the field order of SPEC §6.1."""
134
+ return {
135
+ "schema": RECEIPT_SCHEMA,
136
+ "receipt_id": self.receipt_id,
137
+ "action_id": self.action_id,
138
+ "action": self.action,
139
+ "action_hash": self.action_hash,
140
+ "principal": {"agent": self.principal.agent, "user": self.principal.user},
141
+ "resource": self.resource,
142
+ "arguments": dict(self.arguments),
143
+ "environment": self.environment,
144
+ "decision": str(self.decision),
145
+ "decision_reason": self.decision_reason,
146
+ "approval_id": self.approval_id,
147
+ "approver": self.approver,
148
+ "effect_key": self.effect_key,
149
+ "attempt": self.attempt,
150
+ "result": str(self.result),
151
+ "error": self.error,
152
+ "started_at": iso_timestamp(self.started_at),
153
+ "finished_at": iso_timestamp(self.finished_at),
154
+ }
155
+
156
+ def to_json(self) -> str:
157
+ """One JSONL line. Enums render by value (SPEC-v0.1 §6.1)."""
158
+ return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
159
+
160
+ @classmethod
161
+ def from_dict(cls, document: Mapping[str, Any]) -> Receipt:
162
+ """The inverse of `to_dict`: a receipt read back out of a store or a JSONL file."""
163
+ principal = document["principal"]
164
+ return cls(
165
+ receipt_id=document["receipt_id"],
166
+ action_id=document["action_id"],
167
+ action=document["action"],
168
+ action_hash=document["action_hash"],
169
+ principal=Principal(agent=principal["agent"], user=principal["user"]),
170
+ resource=document["resource"],
171
+ arguments=document["arguments"],
172
+ environment=document["environment"],
173
+ decision=Decision(document["decision"]),
174
+ decision_reason=document["decision_reason"],
175
+ approval_id=document["approval_id"],
176
+ approver=document["approver"],
177
+ effect_key=document["effect_key"],
178
+ attempt=document["attempt"],
179
+ result=ReceiptResult(document["result"]),
180
+ error=document["error"],
181
+ started_at=datetime.fromisoformat(document["started_at"]),
182
+ finished_at=datetime.fromisoformat(document["finished_at"]),
183
+ )
184
+
185
+ @classmethod
186
+ def from_json(cls, line: str) -> Receipt:
187
+ """Parse one JSONL line written by `to_json`."""
188
+ document: dict[str, Any] = json.loads(line)
189
+ return cls.from_dict(document)
190
+
191
+
192
+ class EventLog:
193
+ """The JSONL half of the evidence: two append-only files in one directory (SPEC §6).
194
+
195
+ `receipts.jsonl` and `events.jsonl` beside the state database, so `.ctrlrun/` holds the
196
+ whole record of what an agent did. The store is authoritative — these files are the
197
+ portable copy, written after the store accepted the same record.
198
+
199
+ Each write opens, appends one line and closes, so several processes sharing a store
200
+ (SPEC-v0.1 §5.3 E1) interleave whole lines rather than fragments of them.
201
+ """
202
+
203
+ def __init__(self, directory: str | os.PathLike[str]) -> None:
204
+ self._directory = Path(directory)
205
+
206
+ @property
207
+ def directory(self) -> Path:
208
+ return self._directory
209
+
210
+ @property
211
+ def receipts_path(self) -> Path:
212
+ return self._directory / RECEIPTS_FILENAME
213
+
214
+ @property
215
+ def events_path(self) -> Path:
216
+ return self._directory / EVENTS_FILENAME
217
+
218
+ def put_receipt(self, receipt: Receipt) -> None:
219
+ """Append one receipt as a JSON line."""
220
+ self._append(self.receipts_path, receipt.to_json())
221
+
222
+ def append_event(self, event: Event) -> None:
223
+ """Append one event as a JSON line, in the order the store assigned it."""
224
+ self._append(self.events_path, event.to_json())
225
+
226
+ def _append(self, path: Path, line: str) -> None:
227
+ path.parent.mkdir(parents=True, exist_ok=True)
228
+ with path.open("a", encoding="utf-8") as handle:
229
+ handle.write(f"{line}\n")