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/control.py ADDED
@@ -0,0 +1,822 @@
1
+ """The @protect decorator, Control and the ambient action context. Build-list item 3.
2
+
3
+ `Control` is the only place the other modules are composed (ARCHITECTURE §6): it turns a
4
+ function call into an Action, decides it against the policy, runs the executor, and records
5
+ what happened. SPEC-v0.1 §8 freezes the names here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import inspect
12
+ import logging
13
+ import os
14
+ from collections.abc import Callable, Iterator, Mapping
15
+ from contextlib import contextmanager
16
+ from contextvars import ContextVar
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime, timedelta
19
+ from pathlib import Path
20
+ from typing import Any, Final, ParamSpec, TypeVar
21
+
22
+ from .action import Action, Principal
23
+ from .approval import (
24
+ DEFAULT_APPROVAL_TTL,
25
+ Approval,
26
+ ApprovalProvider,
27
+ ApprovalStatus,
28
+ LocalApprovalProvider,
29
+ )
30
+ from .effect import (
31
+ DEFAULT_LEASE,
32
+ UNRESOLVED_EFFECT,
33
+ Reservation,
34
+ resolve_effect_key,
35
+ resolve_resource,
36
+ template_placeholders,
37
+ )
38
+ from .errors import (
39
+ ActionDenied,
40
+ AmbiguousEffect,
41
+ ApprovalMismatch,
42
+ ApprovalRequired,
43
+ DuplicateEffect,
44
+ EffectKeyError,
45
+ InvalidArgument,
46
+ NotExecuted,
47
+ )
48
+ from .policy import Decision, Evaluation, Policy, discover_policy_path
49
+ from .receipt import Event, EventType, Receipt, ReceiptResult, iso_timestamp, new_receipt_id
50
+ from .state import SQLiteStateStore, StateStore
51
+
52
+ _LOG = logging.getLogger(__name__)
53
+
54
+ P = ParamSpec("P")
55
+ R = TypeVar("R")
56
+
57
+ DEFAULT_ENVIRONMENT: Final = "production"
58
+
59
+ #: Denial reason when a protected function is called outside `ctrlrun.context()`.
60
+ NO_PRINCIPAL: Final = "no_principal"
61
+
62
+ #: Where `Control.from_file` keeps its store, and the env var that overrides it (SPEC §8).
63
+ STATE_ENV_VAR: Final = "CTRLRUN_STATE"
64
+ DEFAULT_STATE_DIR: Final = ".ctrlrun"
65
+ DEFAULT_STATE_FILENAME: Final = "state.db"
66
+
67
+
68
+ def _utc_now() -> datetime:
69
+ return datetime.now(UTC)
70
+
71
+
72
+ # --- the ambient context ---------------------------------------------------------------
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class _Invocation:
77
+ principal: Principal
78
+ environment: str
79
+
80
+
81
+ _CONTEXT: ContextVar[_Invocation] = ContextVar("ctrlrun_context")
82
+ _PRESENTED_APPROVAL: ContextVar[str] = ContextVar("ctrlrun_approval")
83
+
84
+
85
+ @contextmanager
86
+ def context(agent: str, user: str | None = None, environment: str | None = None) -> Iterator[None]:
87
+ """Bind the principal (and optionally the environment) for calls made inside the block.
88
+
89
+ A protected function called outside any `context()` has no principal and is denied
90
+ (SPEC-v0.1 §2.1). A nested `context()` that omits `environment` inherits the enclosing
91
+ one, so entering a context to change agent cannot silently move a call to production.
92
+ """
93
+ if environment is not None and not environment:
94
+ raise InvalidArgument("context(environment=...) must be a non-empty string or None")
95
+ enclosing = _CONTEXT.get(None)
96
+ if environment is not None:
97
+ resolved = environment
98
+ elif enclosing is not None:
99
+ resolved = enclosing.environment
100
+ else:
101
+ resolved = DEFAULT_ENVIRONMENT
102
+ token = _CONTEXT.set(_Invocation(Principal(agent=agent, user=user), resolved))
103
+ try:
104
+ yield
105
+ finally:
106
+ _CONTEXT.reset(token)
107
+
108
+
109
+ @contextmanager
110
+ def with_approval(request_id: str) -> Iterator[None]:
111
+ """Present a granted approval to the calls made inside the block (SPEC-v0.1 §4.3).
112
+
113
+ The approval still has to match: it authorizes the exact action a human saw, once, and
114
+ only until it expires. Presenting it is an offer, not a decision.
115
+ """
116
+ if not request_id:
117
+ raise InvalidArgument("with_approval(request_id) must be a non-empty request id")
118
+ token = _PRESENTED_APPROVAL.set(request_id)
119
+ try:
120
+ yield
121
+ finally:
122
+ _PRESENTED_APPROVAL.reset(token)
123
+
124
+
125
+ # --- Control ---------------------------------------------------------------------------
126
+
127
+
128
+ class Control:
129
+ """Policy, state and evidence composed around a single action (SPEC-v0.1 §8).
130
+
131
+ `evaluate()` decides an action and touches nothing. `execute()` decides it, runs the
132
+ executor, and records a receipt and events for whatever happened.
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ policy: Policy,
138
+ store: StateStore,
139
+ approvals: ApprovalProvider | None = None,
140
+ *,
141
+ clock: Callable[[], datetime] = _utc_now,
142
+ approval_ttl: timedelta = DEFAULT_APPROVAL_TTL,
143
+ lease: timedelta = DEFAULT_LEASE,
144
+ ) -> None:
145
+ self._policy = policy
146
+ self._store = store
147
+ self._approvals: ApprovalProvider = (
148
+ approvals if approvals is not None else LocalApprovalProvider(store, clock=clock)
149
+ )
150
+ self._clock = clock
151
+ self._approval_ttl = approval_ttl
152
+ self._lease = _checked_lease(lease, "Control(lease=...)")
153
+
154
+ @classmethod
155
+ def from_file(cls, path: str | os.PathLike[str] | None = None) -> Control:
156
+ """Build a Control from a policy file (`$CTRLRUN_CONFIG`, else `./ctrlrun.yaml`).
157
+
158
+ A missing or malformed policy raises `PolicyError`: a Control cannot be constructed
159
+ without one (SPEC-v0.1 §3.4). State lands in `.ctrlrun/state.db` beside the policy —
160
+ or wherever `$CTRLRUN_STATE` says — so approvals and effects outlive the process and
161
+ are shared by every worker that loaded the same policy (§5.3 E1, §8).
162
+ """
163
+ policy = Policy.from_file(path)
164
+ store = SQLiteStateStore(state_path(policy.source))
165
+ return cls(policy, store, LocalApprovalProvider(store))
166
+
167
+ @property
168
+ def policy(self) -> Policy:
169
+ return self._policy
170
+
171
+ @property
172
+ def store(self) -> StateStore:
173
+ return self._store
174
+
175
+ @property
176
+ def approvals(self) -> ApprovalProvider:
177
+ return self._approvals
178
+
179
+ @property
180
+ def lease(self) -> timedelta:
181
+ """How long a reservation this Control takes is held for (SPEC-v0.1 §5.3 E3)."""
182
+ return self._lease
183
+
184
+ def evaluate(self, action: Action) -> Evaluation:
185
+ """Decide an action. No side effects: nothing is recorded (SPEC-v0.1 §8)."""
186
+ return self._policy.evaluate(action)
187
+
188
+ def execute(
189
+ self,
190
+ action: Action,
191
+ executor: Callable[[], Any],
192
+ effect_key: str | None = None,
193
+ *,
194
+ lease: timedelta | None = None,
195
+ ) -> Receipt:
196
+ """Decide, run and record one action. Returns the receipt for its terminal state.
197
+
198
+ The executor's own exceptions propagate: `NotExecuted` after a `failed` receipt,
199
+ anything else after an `ambiguous` one (SPEC-v0.1 §5.5). The exception is an effect
200
+ record that moved on while the executor ran — the lease lapsed and a human owns it
201
+ now — where the store's refusal propagates instead, after an `ambiguous` receipt.
202
+
203
+ `effect_key` is the already-resolved logical effect identity (§5.1); it is recorded
204
+ on the receipt and on every event for this action. `lease` is how long this action's
205
+ reservation is held; `None` means this Control's lease (§5.3 E3).
206
+ """
207
+ if effect_key is not None and not effect_key:
208
+ raise InvalidArgument("effect_key must be a non-empty string or None")
209
+ held = self._lease if lease is None else _checked_lease(lease, "execute(lease=...)")
210
+
211
+ started_at = self._clock()
212
+ self._append(
213
+ EventType.ACTION_PROPOSED, action, {"action_hash": action.action_hash}, effect_key
214
+ )
215
+ evaluation = self._policy.evaluate(action)
216
+ self._append(
217
+ EventType.POLICY_EVALUATED,
218
+ action,
219
+ {"decision": str(evaluation.decision), "reason": evaluation.reason},
220
+ effect_key,
221
+ )
222
+
223
+ if evaluation.decision is Decision.DENY:
224
+ self._append(EventType.ACTION_DENIED, action, {"reason": evaluation.reason}, effect_key)
225
+ self._record(
226
+ action, evaluation, ReceiptResult.DENIED, started_at, effect_key=effect_key
227
+ )
228
+ raise ActionDenied(
229
+ f"{action.name} denied: {evaluation.reason}",
230
+ reason=evaluation.reason,
231
+ action_id=action.action_id,
232
+ )
233
+ approval, reservation = self._secure(action, evaluation, started_at, effect_key, held)
234
+ attempt = 1 if reservation is None else reservation.attempt
235
+
236
+ if effect_key is not None:
237
+ try:
238
+ self._store.begin_execution(effect_key, action.action_id)
239
+ except (DuplicateEffect, AmbiguousEffect) as refused:
240
+ # The key was taken from under this attempt between winning it and starting:
241
+ # its lease expired and another attempt declared the effect AMBIGUOUS. The
242
+ # refusal is terminal for this proposal, so it gets a receipt like any other.
243
+ self._refused(
244
+ action, evaluation, started_at, effect_key, refused, approval=approval
245
+ )
246
+ raise
247
+ self._append(EventType.EXECUTION_STARTED, action, {}, effect_key, approval=approval)
248
+ try:
249
+ result = executor()
250
+ except NotExecuted as exc:
251
+ if effect_key is not None:
252
+ # SPEC §5.5 — the executor asserted the remote side did nothing, so this is
253
+ # the one outcome that leaves the key retryable (§5.4).
254
+ try:
255
+ self._store.fail_effect(effect_key, action.action_id, str(exc))
256
+ except (DuplicateEffect, AmbiguousEffect) as refused:
257
+ # SPEC: §5.2 — the record moved on while the executor ran: this attempt's
258
+ # lease lapsed and another declared the effect AMBIGUOUS, which only a
259
+ # human moves it out of. The store's refusal is what propagates, not the
260
+ # NotExecuted: an agent that caught that would retry a key it lost.
261
+ self._unrecorded(
262
+ action, evaluation, started_at, effect_key, attempt, refused, approval
263
+ )
264
+ raise
265
+ self._append(
266
+ EventType.EXECUTION_FAILED,
267
+ action,
268
+ {"error": str(exc)},
269
+ effect_key,
270
+ approval=approval,
271
+ )
272
+ self._record(
273
+ action,
274
+ evaluation,
275
+ ReceiptResult.FAILED,
276
+ started_at,
277
+ error=str(exc),
278
+ approval=approval,
279
+ effect_key=effect_key,
280
+ attempt=attempt,
281
+ )
282
+ raise
283
+ except BaseException as exc:
284
+ # SPEC: §5.5 — anything that is not NotExecuted is an AMBIGUOUS outcome, never
285
+ # FAILED, and "anything" means BaseException: a KeyboardInterrupt mid-request
286
+ # leaves the same unknown outcome a timeout does. Narrowing this to Exception
287
+ # is a regression, not a cleanup.
288
+ error = f"{type(exc).__name__}: {exc}"
289
+ if effect_key is not None:
290
+ try:
291
+ self._store.mark_ambiguous(effect_key, action.action_id, error)
292
+ except (DuplicateEffect, AmbiguousEffect) as refused:
293
+ # Recording an unknown outcome must never mask the exception that caused
294
+ # it. A store that refuses here has the record in a state a human already
295
+ # owns — where this attempt was trying to put it — and the receipt below
296
+ # says `ambiguous` either way.
297
+ _LOG.warning("%s: effect %s: %s", action.name, effect_key, refused)
298
+ self._append(
299
+ EventType.EXECUTION_AMBIGUOUS,
300
+ action,
301
+ {"error": error},
302
+ effect_key,
303
+ approval=approval,
304
+ )
305
+ self._record(
306
+ action,
307
+ evaluation,
308
+ ReceiptResult.AMBIGUOUS,
309
+ started_at,
310
+ error=error,
311
+ approval=approval,
312
+ effect_key=effect_key,
313
+ attempt=attempt,
314
+ )
315
+ raise
316
+ if effect_key is not None:
317
+ try:
318
+ self._store.commit_effect(effect_key, action.action_id, result)
319
+ except (DuplicateEffect, AmbiguousEffect) as refused:
320
+ # SPEC: §5.2 — the executor returned, but the key is no longer this attempt's
321
+ # to commit: the lease lapsed and the record is AMBIGUOUS until a human says
322
+ # otherwise. What happened at the remote is now as unknown as a timeout, so
323
+ # the attempt is recorded `ambiguous` rather than committed.
324
+ self._unrecorded(
325
+ action, evaluation, started_at, effect_key, attempt, refused, approval
326
+ )
327
+ raise
328
+ self._append(EventType.EXECUTION_COMMITTED, action, {}, effect_key, approval=approval)
329
+ return self._record(
330
+ action,
331
+ evaluation,
332
+ ReceiptResult.COMMITTED,
333
+ started_at,
334
+ approval=approval,
335
+ effect_key=effect_key,
336
+ attempt=attempt,
337
+ )
338
+
339
+ # --- the effect key (SPEC-v0.1 §5.1) ------------------------------------------------
340
+
341
+ def _resolve_effect(self, action: Action, template: str | None) -> str | None:
342
+ """Resolve an effect template against a constructed action, or deny the action.
343
+
344
+ A missing placeholder is never a silent `None` (§5.1): the action is refused and
345
+ recorded as denied, before the policy is consulted. An action whose logical effect
346
+ cannot be identified cannot be protected against duplication, whatever the policy
347
+ would have said about it — and unlike a call outside `context()` (§2.1) there is a
348
+ principal here, so the refusal belongs in the evidence log.
349
+ """
350
+ if template is None:
351
+ return None
352
+ started_at = self._clock()
353
+ try:
354
+ return resolve_effect_key(template, action)
355
+ except EffectKeyError as exc:
356
+ self._append(EventType.ACTION_PROPOSED, action, {"action_hash": action.action_hash})
357
+ self._append(
358
+ EventType.ACTION_DENIED,
359
+ action,
360
+ {"reason": UNRESOLVED_EFFECT, "effect": template, "error": str(exc)},
361
+ )
362
+ # SPEC: §6.1 — a receipt needs a decision and the policy never rendered one, so
363
+ # the fail-closed value is recorded: denied, for a reason that is not a rule.
364
+ self._record(
365
+ action,
366
+ Evaluation(Decision.DENY, UNRESOLVED_EFFECT),
367
+ ReceiptResult.DENIED,
368
+ started_at,
369
+ error=str(exc),
370
+ )
371
+ raise
372
+
373
+ # --- authority and the effect key (SPEC-v0.1 §4.2, §5.3) --------------------------
374
+
375
+ def _secure(
376
+ self,
377
+ action: Action,
378
+ evaluation: Evaluation,
379
+ started_at: datetime,
380
+ effect_key: str | None,
381
+ lease: timedelta,
382
+ ) -> tuple[Approval | None, Reservation | None]:
383
+ """Take everything this action needs before it may run: the grant, and the key.
384
+
385
+ Both are taken in one store transaction (SPEC-v0.1 §4.2 A4). The approval is checked
386
+ first, so a replayed approval is what gets raised when a duplicate effect would also
387
+ apply (T4), and a refused reservation leaves the approval granted for the action the
388
+ human actually saw (T12).
389
+ """
390
+ approval_id = (
391
+ self._presented(action, effect_key) if evaluation.decision is Decision.APPROVE else None
392
+ )
393
+ if approval_id is None and effect_key is None:
394
+ return None, None
395
+
396
+ try:
397
+ approval, reservation = self._take(action, approval_id, effect_key, lease)
398
+ except ActionDenied as denied:
399
+ # The store raises this when the record says a human refused. §4.2 makes that a
400
+ # denial of the action, not a mismatch: nothing about the action was wrong.
401
+ approver = self._approver_of(approval_id)
402
+ self._append(
403
+ EventType.APPROVAL_DENIED,
404
+ action,
405
+ {"approver": approver},
406
+ effect_key,
407
+ approval_id=approval_id,
408
+ )
409
+ self._append(EventType.ACTION_DENIED, action, {"reason": denied.reason}, effect_key)
410
+ self._record(
411
+ action,
412
+ evaluation,
413
+ ReceiptResult.DENIED,
414
+ started_at,
415
+ error=str(denied),
416
+ approval_id=approval_id,
417
+ approver=approver,
418
+ effect_key=effect_key,
419
+ )
420
+ raise ActionDenied(
421
+ str(denied), reason=denied.reason, action_id=action.action_id
422
+ ) from denied
423
+ except ApprovalMismatch as mismatch:
424
+ if mismatch.reason == ApprovalStatus.EXPIRED:
425
+ self._append(
426
+ EventType.APPROVAL_EXPIRED, action, {}, effect_key, approval_id=approval_id
427
+ )
428
+ self._append(
429
+ EventType.APPROVAL_INVALIDATED,
430
+ action,
431
+ {"reason": mismatch.reason, "action_hash": action.action_hash},
432
+ effect_key,
433
+ approval_id=approval_id,
434
+ )
435
+ self._record(
436
+ action,
437
+ evaluation,
438
+ ReceiptResult.BLOCKED,
439
+ started_at,
440
+ error=str(mismatch),
441
+ approval_id=approval_id,
442
+ approver=self._approver_of(approval_id),
443
+ effect_key=effect_key,
444
+ )
445
+ raise
446
+ except (DuplicateEffect, AmbiguousEffect) as refused:
447
+ # SPEC §5.4 — this effect already happened, is happening, or ended unknown. The
448
+ # approval, if one was presented, was not consumed: it is still worth something.
449
+ self._refused(
450
+ action, evaluation, started_at, effect_key, refused, approval_id=approval_id
451
+ )
452
+ raise
453
+
454
+ if approval is not None:
455
+ self._append(
456
+ EventType.APPROVAL_CONSUMED,
457
+ action,
458
+ {"approver": approval.approver},
459
+ effect_key,
460
+ approval_id=approval.approval_id,
461
+ )
462
+ if reservation is not None:
463
+ self._append(
464
+ EventType.EFFECT_RESERVED,
465
+ action,
466
+ {
467
+ "attempt": reservation.attempt,
468
+ "lease_expires_at": iso_timestamp(reservation.lease_expires_at),
469
+ },
470
+ effect_key,
471
+ approval=approval,
472
+ )
473
+ return approval, reservation
474
+
475
+ def _refused(
476
+ self,
477
+ action: Action,
478
+ evaluation: Evaluation,
479
+ started_at: datetime,
480
+ effect_key: str | None,
481
+ refused: DuplicateEffect | AmbiguousEffect,
482
+ *,
483
+ approval: Approval | None = None,
484
+ approval_id: str | None = None,
485
+ ) -> None:
486
+ """Record a refusal by the effect key: the event, and a `blocked` receipt (§6.1)."""
487
+ presented = approval.approval_id if approval is not None else approval_id
488
+ self._append(
489
+ EventType.EFFECT_RESERVATION_REFUSED,
490
+ action,
491
+ _refusal_data(refused),
492
+ effect_key,
493
+ approval_id=presented,
494
+ )
495
+ self._record(
496
+ action,
497
+ evaluation,
498
+ ReceiptResult.BLOCKED,
499
+ started_at,
500
+ error=str(refused),
501
+ approval_id=presented,
502
+ approver=self._approver_of(presented),
503
+ effect_key=effect_key,
504
+ )
505
+
506
+ def _unrecorded(
507
+ self,
508
+ action: Action,
509
+ evaluation: Evaluation,
510
+ started_at: datetime,
511
+ effect_key: str,
512
+ attempt: int,
513
+ refused: DuplicateEffect | AmbiguousEffect,
514
+ approval: Approval | None,
515
+ ) -> None:
516
+ """Record an outcome the store refused to write (SPEC-v0.1 §5.2, §5.5).
517
+
518
+ The executor finished, but the effect record moved on while it ran, so what happened
519
+ at the remote is exactly as unknown as a timeout: `ambiguous`, whatever the executor
520
+ returned or raised. A terminal action still gets its receipt (§6.1).
521
+ """
522
+ error = f"{type(refused).__name__}: {refused}"
523
+ self._append(
524
+ EventType.EXECUTION_AMBIGUOUS, action, {"error": error}, effect_key, approval=approval
525
+ )
526
+ self._record(
527
+ action,
528
+ evaluation,
529
+ ReceiptResult.AMBIGUOUS,
530
+ started_at,
531
+ error=error,
532
+ approval=approval,
533
+ effect_key=effect_key,
534
+ attempt=attempt,
535
+ )
536
+
537
+ def _presented(self, action: Action, effect_key: str | None) -> str:
538
+ """The approval this call presents, or record a request and suspend the action.
539
+
540
+ With nothing presented, `ApprovalRequired` is raised so the caller can come back with
541
+ `with_approval(request_id)` (SPEC-v0.1 §4.3). No receipt is written for that: the
542
+ action is suspended awaiting a human, which is not a terminal state (§6.1). The
543
+ `APPROVAL_REQUESTED` event is the evidence.
544
+ """
545
+ presented = _PRESENTED_APPROVAL.get(None)
546
+ if presented is not None:
547
+ return presented
548
+ request = self._approvals.request(action, self._approval_ttl)
549
+ self._append(
550
+ EventType.APPROVAL_REQUESTED,
551
+ action,
552
+ {"action_hash": action.action_hash, "expires_at": iso_timestamp(request.expires_at)},
553
+ effect_key,
554
+ approval_id=request.request_id,
555
+ )
556
+ raise ApprovalRequired(
557
+ f"{action.name} requires approval: run 'ctrlrun approve {request.request_id}', "
558
+ f"then retry inside ctrlrun.with_approval({request.request_id!r})",
559
+ request_id=request.request_id,
560
+ action_id=action.action_id,
561
+ )
562
+
563
+ def _take(
564
+ self, action: Action, approval_id: str | None, effect_key: str | None, lease: timedelta
565
+ ) -> tuple[Approval | None, Reservation | None]:
566
+ """Consume the approval, reserve the effect, or both at once (SPEC-v0.1 §4.2 A4)."""
567
+ if approval_id is not None and effect_key is not None:
568
+ return self._store.consume_approval_and_reserve(
569
+ approval_id, action.action_hash, effect_key, action.action_id, lease
570
+ )
571
+ if approval_id is not None:
572
+ return self._store.consume_approval(approval_id, action.action_hash), None
573
+ if effect_key is not None:
574
+ return None, self._store.reserve_effect(effect_key, action.action_id, lease)
575
+ return None, None
576
+
577
+ def _approver_of(self, approval_id: str | None) -> str | None:
578
+ """Who answered, for the evidence trail. `None` if there is no such record."""
579
+ if approval_id is None:
580
+ return None
581
+ record = self._store.get_approval(approval_id)
582
+ return None if record is None else record.approver
583
+
584
+ # --- evidence ---------------------------------------------------------------------
585
+
586
+ def _append(
587
+ self,
588
+ type_: EventType,
589
+ action: Action,
590
+ data: Mapping[str, Any],
591
+ effect_key: str | None = None,
592
+ *,
593
+ approval: Approval | None = None,
594
+ approval_id: str | None = None,
595
+ ) -> None:
596
+ self._store.append_event(
597
+ Event(
598
+ type=type_,
599
+ action_id=action.action_id,
600
+ ts=self._clock(),
601
+ data=data,
602
+ effect_key=effect_key,
603
+ approval_id=approval_id if approval is None else approval.approval_id,
604
+ )
605
+ )
606
+
607
+ def _record(
608
+ self,
609
+ action: Action,
610
+ evaluation: Evaluation,
611
+ result: ReceiptResult,
612
+ started_at: datetime,
613
+ error: str | None = None,
614
+ *,
615
+ approval: Approval | None = None,
616
+ approval_id: str | None = None,
617
+ approver: str | None = None,
618
+ effect_key: str | None = None,
619
+ attempt: int = 1,
620
+ ) -> Receipt:
621
+ receipt = Receipt(
622
+ receipt_id=new_receipt_id(),
623
+ action_id=action.action_id,
624
+ action=action.name,
625
+ action_hash=action.action_hash,
626
+ principal=action.principal,
627
+ resource=action.resource,
628
+ arguments=action.canonical_arguments,
629
+ environment=action.environment,
630
+ decision=evaluation.decision,
631
+ decision_reason=evaluation.reason,
632
+ approval_id=approval.approval_id if approval is not None else approval_id,
633
+ approver=approval.approver if approval is not None else approver,
634
+ effect_key=effect_key,
635
+ attempt=attempt,
636
+ result=result,
637
+ started_at=started_at,
638
+ finished_at=self._clock(),
639
+ error=error,
640
+ )
641
+ self._store.put_receipt(receipt)
642
+ return receipt
643
+
644
+
645
+ def _refusal_data(refused: DuplicateEffect | AmbiguousEffect) -> dict[str, str]:
646
+ """Why a reservation was refused, for `EFFECT_RESERVATION_REFUSED` (SPEC §5.4, §6.2)."""
647
+ if isinstance(refused, DuplicateEffect):
648
+ return {"reason": "duplicate", "state": refused.state}
649
+ return {"reason": "ambiguous"}
650
+
651
+
652
+ def state_path(source: str | os.PathLike[str] | None = None) -> Path:
653
+ """Where the state database lives for a given policy file (SPEC-v0.1 §8).
654
+
655
+ `.ctrlrun/state.db` beside the policy file, unless `$CTRLRUN_STATE` names somewhere else.
656
+ Beside the policy, not beside the process: workers started from different directories but
657
+ sharing a policy must share one store, or reservation is atomic within each of them and
658
+ meaningless between them (§5.3 E1). Set but empty is a misconfiguration, not a licence to
659
+ fall back to the default — an agent's effects would land in a store nobody is watching.
660
+
661
+ `source=None` discovers the policy the way `Control.from_file` does, which is how the CLI
662
+ finds the store an agent is using without needing to load the policy itself.
663
+ """
664
+ configured = os.environ.get(STATE_ENV_VAR)
665
+ if configured is not None:
666
+ if not configured.strip():
667
+ raise InvalidArgument(
668
+ f"{STATE_ENV_VAR} is set but empty; unset it or point it at a state database"
669
+ )
670
+ return Path(configured)
671
+ resolved = discover_policy_path() if source is None else Path(source)
672
+ return resolved.parent / DEFAULT_STATE_DIR / DEFAULT_STATE_FILENAME
673
+
674
+
675
+ _DEFAULT_CONTROL: Control | None = None
676
+
677
+
678
+ def _default_control() -> Control:
679
+ global _DEFAULT_CONTROL
680
+ if _DEFAULT_CONTROL is None:
681
+ _DEFAULT_CONTROL = Control.from_file()
682
+ return _DEFAULT_CONTROL
683
+
684
+
685
+ # --- the decorator ----------------------------------------------------------------------
686
+
687
+
688
+ def protect(
689
+ name: str,
690
+ *,
691
+ effect: str | None = None,
692
+ resource: str | None = None,
693
+ wait: bool = False,
694
+ lease: timedelta | None = None,
695
+ control: Control | None = None,
696
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
697
+ """Bind a function to an action name: every call becomes a decided, recorded Action.
698
+
699
+ The wrapped function is the executor (SPEC-v0.1 §5.5), and it is invoked with the
700
+ action's canonical arguments, never with the caller's own objects (§2.2).
701
+
702
+ `effect` and `resource` are templates over the call's arguments (§5.1). Their syntax is
703
+ checked here, at decoration time, so a typo fails at import rather than mid-agent-run.
704
+
705
+ `lease` is how long this action's reservation is held (§5.3 E3), for work that takes
706
+ longer than the Control's default; it overrides that default and nothing else. Expiry
707
+ means what it always meant: past the lease the effect is `AMBIGUOUS`, never released.
708
+ """
709
+ if not name:
710
+ raise InvalidArgument("protect(name=...) must be a non-empty action name")
711
+ _check_template(name, "effect", effect)
712
+ _check_template(name, "resource", resource)
713
+ held = None if lease is None else _checked_lease(lease, f"protect({name!r}, lease=...)")
714
+
715
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
716
+ signature = inspect.signature(func)
717
+ _reject_variadic(signature, name)
718
+
719
+ @functools.wraps(func)
720
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
721
+ invocation = _CONTEXT.get(None)
722
+ if invocation is None:
723
+ # SPEC-v0.1 §2.1 — a call outside context() is a wiring bug, not an agent
724
+ # action: it is denied and warned about, but never enters the evidence log,
725
+ # which records actions and has no principal to attribute this one to.
726
+ _LOG.warning("%s: denied: no ctrlrun.context() is active", name)
727
+ raise ActionDenied(
728
+ f"{name}: no ctrlrun.context() is active; wrap the call in "
729
+ "'with ctrlrun.context(agent=...)'",
730
+ reason=NO_PRINCIPAL,
731
+ )
732
+ bound = signature.bind(*args, **kwargs)
733
+ bound.apply_defaults()
734
+ action = Action(
735
+ name=name,
736
+ arguments=dict(bound.arguments),
737
+ principal=invocation.principal,
738
+ # SPEC-v0.1 §5.1 — `resource` is part of the canonical form, so it resolves
739
+ # before the Action exists; the effect template resolves against the Action.
740
+ resource=None if resource is None else resolve_resource(resource, bound.arguments),
741
+ environment=invocation.environment,
742
+ )
743
+ resolved = control if control is not None else _default_control()
744
+ effect_key = resolved._resolve_effect(action, effect)
745
+ returned: list[R] = []
746
+
747
+ def executor() -> R:
748
+ value = _invoke(func, signature, action.canonical_arguments)
749
+ returned.append(value)
750
+ return value
751
+
752
+ try:
753
+ resolved.execute(action, executor, effect_key, lease=held)
754
+ except ApprovalRequired as pending:
755
+ if not wait:
756
+ raise
757
+ # SPEC-v0.1 §4.3 — with wait=True the decorator blocks on the provider and
758
+ # then re-presents the same proposal. It re-presents a *denied* answer too:
759
+ # the refusal belongs in the receipt Control writes, not in the decorator.
760
+ resolved.approvals.wait(pending.request_id, None)
761
+ with with_approval(pending.request_id):
762
+ resolved.execute(action, executor, effect_key, lease=held)
763
+ return returned[0]
764
+
765
+ return wrapper
766
+
767
+ return decorator
768
+
769
+
770
+ def _checked_lease(lease: object, where: str) -> timedelta:
771
+ """A lease must be a positive `timedelta` (SPEC-v0.1 §5.3 E3).
772
+
773
+ Checked wherever one is offered — `Control`, `execute`, `protect` — because each is a
774
+ separate way in, and a lease that has already expired reserves nothing: the first
775
+ contender would find the record expired and declare a perfectly healthy effect ambiguous.
776
+ """
777
+ if not isinstance(lease, timedelta):
778
+ raise InvalidArgument(f"{where}: lease must be a timedelta, not {type(lease).__name__}")
779
+ if lease <= timedelta(0):
780
+ raise InvalidArgument(f"{where}: lease must be positive, got {lease!r}")
781
+ return lease
782
+
783
+
784
+ def _check_template(name: str, kwarg: str, template: str | None) -> None:
785
+ """Reject a malformed `effect=` / `resource=` template at decoration time (SPEC §5.1)."""
786
+ if template is None:
787
+ return
788
+ try:
789
+ template_placeholders(template)
790
+ except InvalidArgument as exc:
791
+ raise InvalidArgument(f"protect({name!r}, {kwarg}=...): {exc}") from exc
792
+
793
+
794
+ def _reject_variadic(signature: inspect.Signature, name: str) -> None:
795
+ """Refuse `*args` / `**kwargs` on a protected function.
796
+
797
+ SPEC: §8 — an Action's arguments are a mapping of named values (§2.1); policy conditions
798
+ and templates address them by name. A variadic parameter has no such name, so it could
799
+ never be written into a rule. Positional-only and keyword-only parameters are fine.
800
+ """
801
+ variadic = inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD
802
+ offending = [
803
+ parameter.name for parameter in signature.parameters.values() if parameter.kind in variadic
804
+ ]
805
+ if offending:
806
+ raise InvalidArgument(
807
+ f"protect({name!r}): a protected function cannot take *args or **kwargs "
808
+ f"({', '.join(offending)}); every argument must be nameable in policy"
809
+ )
810
+
811
+
812
+ def _invoke(
813
+ func: Callable[..., R], signature: inspect.Signature, arguments: Mapping[str, Any]
814
+ ) -> R:
815
+ """Call `func` with `arguments`, respecting positional-only parameters."""
816
+ positional = [
817
+ parameter.name
818
+ for parameter in signature.parameters.values()
819
+ if parameter.kind is inspect.Parameter.POSITIONAL_ONLY
820
+ ]
821
+ keyword = {key: value for key, value in arguments.items() if key not in positional}
822
+ return func(*(arguments[key] for key in positional), **keyword)