wardn 0.1.0.dev0__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.
wardn/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """wardn — bounded, auditable action governor for LLM agents.
2
+
3
+ The MCP stdio proxy (`wardn proxy`) is the primary surface. The engine API
4
+ below is public: `govern_call` + `Engine` + `LedgerWriter` are the whole
5
+ enforcement loop for embedding wardn in front of any tool-calling boundary,
6
+ MCP or not (see docs/embedding.md). Compatibility promises attach to the
7
+ ledger format and the proxy CLI; the Python API aims for the same but may
8
+ still move in minor versions before 1.0.
9
+ """
10
+
11
+ from ._version import __version__
12
+ from .approvals import QueueError
13
+ from .diff import LedgerDiff, RunSummary, diff_ledgers, render_diff
14
+ from .engine import Engine, classify
15
+ from .ledger import (LEDGER_FORMAT, Folded, LedgerError, LedgerWriter,
16
+ ReplayDivergence, fold, read_ledger, replay)
17
+ from .orchestrator import govern_call, refusal_result, text_of
18
+ from .policy import (POLICY_FORMAT, Budget, Policy, PolicyError, Rule,
19
+ policy_from_json)
20
+ from .revert import RevertError, RevertMismatch, revert
21
+ from .types import Decision, ToolAnnotations, ToolRequest, WardnError
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "Budget", "Decision", "Engine", "Folded", "LEDGER_FORMAT", "LedgerDiff",
26
+ "LedgerError", "LedgerWriter", "POLICY_FORMAT", "Policy", "PolicyError",
27
+ "QueueError", "ReplayDivergence", "RevertError", "RevertMismatch",
28
+ "Rule", "RunSummary", "ToolAnnotations", "ToolRequest", "WardnError",
29
+ "classify", "diff_ledgers", "fold", "govern_call", "policy_from_json",
30
+ "read_ledger", "refusal_result", "render_diff", "replay", "revert",
31
+ "text_of",
32
+ ]
wardn/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
wardn/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0.dev0"
wardn/approvals.py ADDED
@@ -0,0 +1,103 @@
1
+ """The approval queue: one JSON file per run, shared between the proxy
2
+ (which parks and polls) and `wardn review` (which lists and decides).
3
+
4
+ Writes are atomic (temp file + os.replace) and reads retry briefly, since
5
+ the two sides race on Windows file locks. Verdicts land in the ledger as
6
+ approval events; the queue file itself is working state, not the record.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from .types import WardnError
17
+
18
+ QUEUE_FORMAT = "wardn-queue/1"
19
+
20
+ _RETRIES = 40
21
+ _RETRY_SLEEP = 0.05
22
+
23
+
24
+ class QueueError(WardnError):
25
+ pass
26
+
27
+
28
+ def _read(path: Path) -> dict:
29
+ if not path.exists():
30
+ return {"format": QUEUE_FORMAT, "items": []}
31
+ for attempt in range(_RETRIES):
32
+ try:
33
+ doc = json.loads(path.read_text(encoding="utf-8"))
34
+ if doc.get("format") != QUEUE_FORMAT:
35
+ raise QueueError(f"{path}: not a {QUEUE_FORMAT} file")
36
+ return doc
37
+ except (json.JSONDecodeError, PermissionError, OSError):
38
+ if attempt == _RETRIES - 1:
39
+ raise
40
+ time.sleep(_RETRY_SLEEP)
41
+ raise QueueError(f"{path}: unreadable")
42
+
43
+
44
+ def _write(path: Path, doc: dict) -> None:
45
+ tmp = path.with_name(path.name + ".tmp")
46
+ for attempt in range(_RETRIES):
47
+ try:
48
+ tmp.write_text(json.dumps(doc, indent=2), encoding="utf-8")
49
+ os.replace(tmp, path)
50
+ return
51
+ except (PermissionError, OSError):
52
+ if attempt == _RETRIES - 1:
53
+ raise
54
+ time.sleep(_RETRY_SLEEP)
55
+
56
+
57
+ def park(path, item_id: str, seq: int, server: str, tool: str, args: dict,
58
+ rule: str, reason: str) -> None:
59
+ path = Path(path)
60
+ doc = _read(path)
61
+ if any(item["id"] == item_id for item in doc["items"]):
62
+ raise QueueError(
63
+ f"{path}: item {item_id!r} already queued — one queue file per "
64
+ "run; point --queue somewhere fresh")
65
+ doc["items"].append({"id": item_id, "seq": seq, "server": server,
66
+ "tool": tool, "args": args, "rule": rule,
67
+ "reason": reason, "verdict": None, "by": None})
68
+ _write(path, doc)
69
+
70
+
71
+ def pending(path) -> list:
72
+ return [item for item in _read(Path(path))["items"]
73
+ if item["verdict"] is None]
74
+
75
+
76
+ def verdict(path, item_id: str):
77
+ """(verdict, by) once decided, else None."""
78
+ for item in _read(Path(path))["items"]:
79
+ if item["id"] == item_id:
80
+ if item["verdict"] is None:
81
+ return None
82
+ return item["verdict"], item["by"]
83
+ return None
84
+
85
+
86
+ def decide(path, item_id: str, decision: str, by: str) -> dict:
87
+ if decision not in ("approve", "deny"):
88
+ raise QueueError(f"verdict must be approve or deny, not {decision!r}")
89
+ if not by:
90
+ raise QueueError("an approver name is required (--by) — verdicts "
91
+ "are recorded, anonymous ones are worthless")
92
+ path = Path(path)
93
+ doc = _read(path)
94
+ for item in doc["items"]:
95
+ if item["id"] == item_id:
96
+ if item["verdict"] is not None:
97
+ raise QueueError(f"{item_id!r} already decided: "
98
+ f"{item['verdict']} by {item['by']}")
99
+ item["verdict"] = decision
100
+ item["by"] = by
101
+ _write(path, doc)
102
+ return item
103
+ raise QueueError(f"{path}: no queued item {item_id!r}")
wardn/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """wardn CLI: proxy, review, replay, revert, diff.
2
+
3
+ Exit codes, family convention: 0 ok, 2 refusal/error (bad policy, replay
4
+ divergence, revert mismatch, ...), 4 drift from `wardn diff`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import approvals
15
+ from ._version import __version__
16
+ from .diff import diff_ledgers, render_diff
17
+ from .ledger import replay
18
+ from .policy import policy_from_json
19
+ from .proxy import Proxy, ServerSpec, servers_from_json, stdio_caller
20
+ from .revert import revert
21
+ from .types import WardnError
22
+
23
+
24
+ def _trailing_cmd(args_list: list) -> list:
25
+ cmd = list(args_list)
26
+ if cmd and cmd[0] == "--":
27
+ cmd = cmd[1:]
28
+ return cmd
29
+
30
+
31
+ def main(argv=None) -> int:
32
+ parser = argparse.ArgumentParser(
33
+ prog="wardn",
34
+ description="bounded, auditable action governor for LLM agents")
35
+ parser.add_argument("--version", action="version",
36
+ version=f"wardn {__version__}")
37
+ sub = parser.add_subparsers(dest="command", required=True)
38
+
39
+ p = sub.add_parser("proxy",
40
+ help="govern one or more MCP servers over stdio")
41
+ p.add_argument("--policy", required=True, help="policy JSON file")
42
+ p.add_argument("--ledger", required=True, help="ledger file to write")
43
+ p.add_argument("--queue", default=None,
44
+ help="approval queue file (default: <ledger>.queue.json)")
45
+ p.add_argument("--servers", default=None, metavar="JSON",
46
+ help="JSON file naming several downstream servers "
47
+ "(MCP client config shape: {\"mcpServers\": {name: "
48
+ "{command, args}}}); instead of a trailing command")
49
+ p.add_argument("--server-name", default="server",
50
+ help="name for the trailing-command server, for policy "
51
+ "matching (default: server)")
52
+ p.add_argument("--run-id", default="run",
53
+ help="workload label in the ledger header — keep it "
54
+ "identical across runs you intend to compare")
55
+ p.add_argument("server_cmd", nargs=argparse.REMAINDER,
56
+ help="-- followed by the downstream MCP server command")
57
+
58
+ r = sub.add_parser("review", help="list or decide quarantined calls")
59
+ r.add_argument("action", choices=["list", "approve", "deny"])
60
+ r.add_argument("item_id", nargs="?")
61
+ r.add_argument("--queue", required=True, help="the run's queue file")
62
+ r.add_argument("--by", default="",
63
+ help="who is deciding (recorded in the ledger)")
64
+
65
+ rp = sub.add_parser("replay",
66
+ help="re-derive every decision from policy + "
67
+ "ledger, offline; no server needed")
68
+ rp.add_argument("--policy", required=True, help="the policy that ran")
69
+ rp.add_argument("--ledger", required=True, help="the run's ledger")
70
+
71
+ rv = sub.add_parser("revert",
72
+ help="undo the run's recorded effects against a "
73
+ "live server, from the ledger alone")
74
+ rv.add_argument("--ledger", required=True, help="the run's ledger")
75
+ rv.add_argument("--out", default=None,
76
+ help="revert ledger to write "
77
+ "(default: <ledger>.revert.jsonl)")
78
+ rv.add_argument("server_cmd", nargs=argparse.REMAINDER,
79
+ help="-- followed by the MCP server command to undo "
80
+ "against")
81
+
82
+ d = sub.add_parser("diff",
83
+ help="compare two ledgers' decisions; exit 4 on drift")
84
+ d.add_argument("ledger_a", help="ledger JSONL (e.g. yesterday's run)")
85
+ d.add_argument("ledger_b", help="ledger JSONL (e.g. today's)")
86
+ d.add_argument("--show-attempts", type=int, default=0, metavar="N",
87
+ help="also list up to N attempts unique to each side")
88
+
89
+ args = parser.parse_args(argv)
90
+ try:
91
+ if args.command == "proxy":
92
+ cmd = _trailing_cmd(args.server_cmd)
93
+ if args.servers and cmd:
94
+ parser.error("give either --servers or a trailing command, "
95
+ "not both")
96
+ if args.servers:
97
+ specs = servers_from_json(Path(args.servers))
98
+ elif cmd:
99
+ specs = [ServerSpec(name=args.server_name, cmd=tuple(cmd))]
100
+ else:
101
+ parser.error("no downstream server (give --servers, or the "
102
+ "command after --)")
103
+ queue = args.queue or (str(args.ledger) + ".queue.json")
104
+ return Proxy(policy=policy_from_json(Path(args.policy)),
105
+ servers=specs, ledger_path=args.ledger,
106
+ queue_path=queue, run_id=args.run_id).run()
107
+
108
+ if args.command == "review":
109
+ if args.action == "list":
110
+ items = approvals.pending(args.queue)
111
+ if not items:
112
+ print("nothing pending")
113
+ return 0
114
+ for item in items:
115
+ print(f"{item['id']} {item['tool']} "
116
+ f"{json.dumps(item['args'], separators=(',', ':'))}"
117
+ f" rule={item['rule']} — {item['reason']}")
118
+ return 0
119
+ if not args.item_id:
120
+ parser.error("approve/deny take the item id (see: review list)")
121
+ item = approvals.decide(args.queue, args.item_id, args.action,
122
+ args.by)
123
+ print(f"{item['id']}: {item['verdict']} by {item['by']}")
124
+ return 0
125
+
126
+ if args.command == "replay":
127
+ count = replay(policy_from_json(Path(args.policy)), args.ledger)
128
+ print(f"replayed {count} attempt(s) — every decision reproduced "
129
+ "by this policy")
130
+ return 0
131
+
132
+ if args.command == "revert":
133
+ cmd = _trailing_cmd(args.server_cmd)
134
+ if not cmd:
135
+ parser.error("no server command (give it after --)")
136
+ out = args.out or (str(args.ledger) + ".revert.jsonl")
137
+ with stdio_caller(cmd) as caller:
138
+ applied = revert(args.ledger, caller, out)
139
+ print(f"applied {applied} undo(s) — revert ledger: {out}")
140
+ return 0
141
+
142
+ if args.command == "diff":
143
+ result = diff_ledgers(args.ledger_a, args.ledger_b)
144
+ print(render_diff(result, show_attempts=args.show_attempts))
145
+ return 0 if result.same else 4
146
+ except WardnError as exc:
147
+ print(f"wardn: {exc}", file=sys.stderr)
148
+ return 2
149
+ return 0
wardn/diff.py ADDED
@@ -0,0 +1,154 @@
1
+ """wardn diff: run-to-run drift in the decision surface.
2
+
3
+ Two ledgers from the same workload should describe the same governed
4
+ activity: same policy, same attempts, same decisions. `diff_ledgers`
5
+ compares exactly that — every attempt with its decision and (for
6
+ quarantined calls) the recorded verdict — and never the server's result
7
+ content: what came back is the server's business, what was attempted and
8
+ allowed is the gate's. Comparison is an order-insensitive multiset, mendr
9
+ style: a run that made the same attempts in a different order with
10
+ identical decisions is not drift. (Bounds are order-dependent, so a
11
+ reorder that changes WHICH call gets refused changes the multiset and is
12
+ drift.)
13
+
14
+ `LedgerDiff.same` is the CI verdict; the CLI turns False into exit 4 —
15
+ the family convention (mendr's diff exits 4 on drift too). Everything
16
+ else is the explanation: per-rule and per-effect counts A -> B, declared
17
+ spend, and the exact attempts unique to each side.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass
24
+
25
+ from .ledger import fold, read_ledger
26
+
27
+ # The decision surface: what the diff compares, per attempt. seq is order
28
+ # (excluded — the multiset is order-insensitive); the approver's name is
29
+ # audit detail, not a decision (excluded); capture and result events are
30
+ # the world's side of the story (excluded).
31
+ _DECISION_FIELDS = ("server", "tool", "args", "matched_rule", "effect",
32
+ "effect_source", "disposition", "reason", "cost",
33
+ "refusal")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class RunSummary:
38
+ """One ledger's governed activity, reduced to what diff compares."""
39
+ run_id: str
40
+ policy_hash: str
41
+ attempts: int
42
+ per_rule: dict # rule id -> {"attempts": n, "committed": n}
43
+ per_effect: dict # effect -> committed count
44
+ spend: float # declared cost of committed calls
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class LedgerDiff:
49
+ """What changed between two runs of (presumably) the same workload.
50
+
51
+ `same` is the CI verdict: identical policy, identical decision
52
+ surface. The count fields are the explanation a human reads when it
53
+ is False."""
54
+ a: RunSummary
55
+ b: RunSummary
56
+ only_in_a: tuple # canonical JSON strings, one per unmatched attempt
57
+ only_in_b: tuple
58
+
59
+ @property
60
+ def policies_match(self) -> bool:
61
+ return self.a.policy_hash == self.b.policy_hash
62
+
63
+ @property
64
+ def same(self) -> bool:
65
+ return self.policies_match and not self.only_in_a \
66
+ and not self.only_in_b
67
+
68
+
69
+ def _summarize(path) -> tuple:
70
+ """(RunSummary, decision-surface multiset) for one ledger."""
71
+ header, events = read_ledger(path)
72
+ per_rule: dict = {}
73
+ per_effect: dict = {}
74
+ spend = 0.0
75
+ multiset: dict = {}
76
+ folded = fold(events)
77
+ for entry in folded:
78
+ attempt = entry.attempt
79
+ rule = per_rule.setdefault(attempt["matched_rule"],
80
+ {"attempts": 0, "committed": 0})
81
+ rule["attempts"] += 1
82
+ if entry.committed:
83
+ rule["committed"] += 1
84
+ per_effect[attempt["effect"]] = \
85
+ per_effect.get(attempt["effect"], 0) + 1
86
+ spend = round(spend + attempt["cost"], 10)
87
+ record = {field: attempt[field] for field in _DECISION_FIELDS}
88
+ record["verdict"] = \
89
+ entry.approval["verdict"] if entry.approval else None
90
+ key = json.dumps(record, sort_keys=True, separators=(",", ":"))
91
+ multiset[key] = multiset.get(key, 0) + 1
92
+ summary = RunSummary(
93
+ run_id=header["run_id"],
94
+ policy_hash=header["provenance"]["policy_hash"],
95
+ attempts=len(folded), per_rule=per_rule, per_effect=per_effect,
96
+ spend=spend)
97
+ return summary, multiset
98
+
99
+
100
+ def _only_in(mine: dict, theirs: dict) -> tuple:
101
+ extra = []
102
+ for key, count in mine.items():
103
+ extra.extend([key] * max(0, count - theirs.get(key, 0)))
104
+ return tuple(sorted(extra))
105
+
106
+
107
+ def diff_ledgers(path_a, path_b) -> LedgerDiff:
108
+ """Compare two ledger files' decision surfaces."""
109
+ a, multiset_a = _summarize(path_a)
110
+ b, multiset_b = _summarize(path_b)
111
+ return LedgerDiff(a=a, b=b,
112
+ only_in_a=_only_in(multiset_a, multiset_b),
113
+ only_in_b=_only_in(multiset_b, multiset_a))
114
+
115
+
116
+ def render_diff(diff: LedgerDiff, show_attempts: int = 0) -> str:
117
+ """Human/CI-readable drift report. `show_attempts` > 0 also lists up
118
+ to that many attempts unique to each side."""
119
+ a, b = diff.a, diff.b
120
+ lines = ["wardn ledger diff"]
121
+ if not diff.policies_match:
122
+ lines.append(f" POLICY DRIFT: {a.policy_hash} vs {b.policy_hash}")
123
+ if a.run_id != b.run_id:
124
+ lines.append(f" note: run_id {a.run_id!r} vs {b.run_id!r} — "
125
+ "comparable runs should share the label")
126
+ lines.append(f" attempts: {a.attempts} -> {b.attempts}")
127
+ for rid in sorted(set(a.per_rule) | set(b.per_rule)):
128
+ ra = a.per_rule.get(rid, {"attempts": 0, "committed": 0})
129
+ rb = b.per_rule.get(rid, {"attempts": 0, "committed": 0})
130
+ marker = " " if ra == rb else "~ "
131
+ lines.append(
132
+ f" {marker}{rid}: attempts {ra['attempts']} -> {rb['attempts']}"
133
+ f", committed {ra['committed']} -> {rb['committed']}"
134
+ f", refused {ra['attempts'] - ra['committed']} -> "
135
+ f"{rb['attempts'] - rb['committed']}")
136
+ for effect in sorted(set(a.per_effect) | set(b.per_effect)):
137
+ na = a.per_effect.get(effect, 0)
138
+ nb = b.per_effect.get(effect, 0)
139
+ marker = " " if na == nb else "~ "
140
+ lines.append(f" {marker}effect {effect}: committed {na} -> {nb}")
141
+ if a.spend or b.spend:
142
+ marker = " " if a.spend == b.spend else "~ "
143
+ lines.append(f" {marker}declared spend: "
144
+ f"{json.dumps(a.spend)} -> {json.dumps(b.spend)}")
145
+ if diff.same:
146
+ lines.append(" ledgers match: same policy, same attempts, "
147
+ "same decisions")
148
+ else:
149
+ lines.append(f" attempts only in A: {len(diff.only_in_a)}; "
150
+ f"only in B: {len(diff.only_in_b)}")
151
+ for label, extras in (("A", diff.only_in_a), ("B", diff.only_in_b)):
152
+ for key in extras[:show_attempts]:
153
+ lines.append(f" only in {label}: {key}")
154
+ return "\n".join(lines)
wardn/engine.py ADDED
@@ -0,0 +1,105 @@
1
+ """The decision engine. Pure bookkeeping, no I/O, no clock.
2
+
3
+ A decision is a pure function of policy + request + annotations + the
4
+ counters accumulated so far; the counters advance only when a call is
5
+ actually committed for forwarding (`note_forwarded`). Offline replay drives
6
+ this same class from the ledger and must land on identical decisions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .matchers import match_matches
12
+ from .policy import Policy, Rule
13
+ from .types import EFFECT_RANK, Decision, ToolAnnotations, ToolRequest
14
+
15
+
16
+ def _annotation_effect(ann: ToolAnnotations | None) -> str | None:
17
+ if ann is None:
18
+ return None
19
+ if ann.read_only:
20
+ return "read"
21
+ if ann.destructive:
22
+ return "destructive"
23
+ if ann.read_only is None and ann.destructive is None:
24
+ return None
25
+ return "write"
26
+
27
+
28
+ def classify(rule: Rule | None, ann: ToolAnnotations | None) -> tuple[str, str]:
29
+ """(effect, source). Declared wins; an annotation may tighten a
30
+ declaration but never loosen it; with neither, a tool is a write."""
31
+ declared = rule.effect if rule else None
32
+ derived = _annotation_effect(ann)
33
+ if declared is not None:
34
+ if derived is not None and EFFECT_RANK[derived] > EFFECT_RANK[declared]:
35
+ return derived, "declared+tightened"
36
+ return declared, "declared"
37
+ if derived is not None:
38
+ return derived, "annotation"
39
+ return "write", "unclassified"
40
+
41
+
42
+ class Engine:
43
+ def __init__(self, policy: Policy):
44
+ self.policy = policy
45
+ self.rule_calls: dict = {} # rule id -> forwarded calls
46
+ self.run_calls = 0
47
+ self.run_writes = 0
48
+ self.run_spend = 0.0
49
+
50
+ # -- deciding -----------------------------------------------------------
51
+
52
+ def decide(self, req: ToolRequest,
53
+ ann: ToolAnnotations | None = None) -> Decision:
54
+ rule = self._match(req)
55
+ effect, effect_source = classify(rule, ann)
56
+ cost = rule.cost_per_call if rule else 0.0
57
+ disposition = rule.disposition if rule else self.policy.default
58
+ matched = rule.id if rule else "default"
59
+ reason = rule.reason if rule else ""
60
+
61
+ if disposition == "deny":
62
+ refusal = f"rule {rule.id}: deny" if rule else "default: no rule matched"
63
+ else:
64
+ # allow and quarantine both eventually forward, so bounds apply
65
+ # to both — and bounds precede approval: nobody gets paged about
66
+ # a call the budget refuses anyway.
67
+ refusal = self._check_bounds(rule, effect, cost)
68
+
69
+ return Decision(matched_rule=matched, effect=effect,
70
+ effect_source=effect_source, disposition=disposition,
71
+ reason=reason, cost=cost, refusal=refusal)
72
+
73
+ def _match(self, req: ToolRequest) -> Rule | None:
74
+ for rule in self.policy.rules:
75
+ if match_matches(rule.match, req.server, req.tool, req.args):
76
+ return rule
77
+ return None
78
+
79
+ def _check_bounds(self, rule: Rule | None, effect: str, cost: float) -> str:
80
+ budget = self.policy.budget
81
+ if rule is not None and rule.max_calls is not None \
82
+ and self.rule_calls.get(rule.id, 0) + 1 > rule.max_calls:
83
+ return f"rule {rule.id}: bound max_calls={rule.max_calls} exceeded"
84
+ if budget.max_calls is not None \
85
+ and self.run_calls + 1 > budget.max_calls:
86
+ return f"budget: max_calls={budget.max_calls} exceeded"
87
+ if effect in ("write", "destructive") and budget.max_writes is not None \
88
+ and self.run_writes + 1 > budget.max_writes:
89
+ return f"budget: max_writes={budget.max_writes} exceeded"
90
+ if budget.max_spend is not None \
91
+ and round(self.run_spend + cost, 10) > budget.max_spend:
92
+ return f"budget: max_spend={budget.max_spend} exceeded"
93
+ return ""
94
+
95
+ # -- bookkeeping --------------------------------------------------------
96
+
97
+ def note_forwarded(self, decision: Decision) -> None:
98
+ """Advance the counters for a call committed for forwarding."""
99
+ if decision.matched_rule != "default":
100
+ self.rule_calls[decision.matched_rule] = \
101
+ self.rule_calls.get(decision.matched_rule, 0) + 1
102
+ self.run_calls += 1
103
+ if decision.effect in ("write", "destructive"):
104
+ self.run_writes += 1
105
+ self.run_spend = round(self.run_spend + decision.cost, 10)