obstat 0.2.0__tar.gz → 0.3.1__tar.gz

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.
@@ -7,10 +7,17 @@ on:
7
7
 
8
8
  jobs:
9
9
  test:
10
- runs-on: ubuntu-latest
10
+ runs-on: ${{ matrix.os }}
11
11
  strategy:
12
12
  matrix:
13
+ os: [ubuntu-latest]
13
14
  python: ["3.11", "3.12", "3.13"]
15
+ # Windows cannot fsync a directory (§8), which the first record write
16
+ # depends on. One leg, so the platform where that is skipped is a
17
+ # platform the tests actually run on.
18
+ include:
19
+ - os: windows-latest
20
+ python: "3.12"
14
21
  steps:
15
22
  - uses: actions/checkout@v4
16
23
  - uses: astral-sh/setup-uv@v5
@@ -7,7 +7,7 @@ that weakens that ordering is a bug, not an optimisation.
7
7
  ## Commands
8
8
 
9
9
  ```bash
10
- uv run pytest -q # 26 tests, ~0.3s
10
+ uv run pytest -q # 42 tests, ~0.9s (TestConcurrency spawns two children)
11
11
  uv run ruff check .
12
12
  uv run ruff format .
13
13
  ```
@@ -44,9 +44,20 @@ when the spec is renumbered.
44
44
  uploads via PyPI trusted publishing (OIDC — there is no token anywhere). **A PyPI
45
45
  version is immutable**: a bad build can be superseded, never withdrawn.
46
46
 
47
- ## Gotcha
47
+ ## Gotchas
48
48
 
49
49
  `uv run --with /path/to/obstat` serves a **cached wheel** and will happily run
50
50
  code you edited minutes ago as if you hadn't. `--reinstall-package` does not
51
51
  dislodge it; `--refresh` does. Two verification runs were wasted on this — if a
52
- local install seems to ignore your change, that is why.
52
+ local install seems to ignore your change, that is why. To exercise uncommitted
53
+ work, `--with-editable` sidesteps the question entirely.
54
+
55
+ **PyPI lags its own publish.** Minutes after `publish.yml` goes green,
56
+ `uv pip install obstat==X` can still fail with *"there is no version of
57
+ obstat==X"* while `https://pypi.org/pypi/obstat/json` reports X as latest — or
58
+ the reverse; 0.2.0 and 0.3.0 each showed one. It cleared inside a minute both
59
+ times. The symptom impersonates a failed upload, and every tempting response
60
+ (re-tag, re-run, `force`) is wrong, one of them irreversibly. **The publish job
61
+ log is authoritative**: it prints the `https://pypi.org/project/obstat/X/` that
62
+ upload.pythonhosted.org returned, which means the file was accepted. Poll the
63
+ install until it succeeds rather than diagnosing it.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obstat
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: An auditable decision record for agent tool calls. The clearance is written down before the call runs.
5
5
  Project-URL: Homepage, https://github.com/marcinmarzeta/obstat
6
6
  Project-URL: Issues, https://github.com/marcinmarzeta/obstat/issues
@@ -47,6 +47,11 @@ is still weak.
47
47
 
48
48
  ## 60 seconds
49
49
 
50
+ ```console
51
+ $ obstat init
52
+ wrote obstat.toml — everything is denied until you uncomment a rule
53
+ ```
54
+
50
55
  `obstat.toml`:
51
56
 
52
57
  ```toml
@@ -173,6 +178,18 @@ permission, and a missing policy file is an error rather than an implicit allow.
173
178
  Patterns are globs. The file is re-read when it changes, so editing policy does
174
179
  not need a restart.
175
180
 
181
+ They are matched **case-sensitively**, against an id built from arguments the
182
+ caller sent. If your namespace is not case-sensitive — Jira keys, most
183
+ filesystems — a rule written for `jira_issue:SEC-*` will not cover `sec-1`, and
184
+ nothing will tell you. Normalise the id where you build it:
185
+
186
+ ```python
187
+ @guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}")
188
+ ```
189
+
190
+ That is what the callable form is for; [§3.3](docs/obstat-spec.md#33-resource-resolution)
191
+ has the whole story, including why obstat does not fold case for you.
192
+
176
193
  ## The order
177
194
 
178
195
  ```
@@ -197,6 +214,8 @@ reads "authorised, outcome unknown", which is the honest state; paying for a sec
197
214
  ## Operator commands
198
215
 
199
216
  ```console
217
+ obstat init # a starter policy; refuses to overwrite one
218
+ obstat check <tool> [res] # what the policy would decide, without a call
200
219
  obstat pending # approvals waiting on a human
201
220
  obstat approve <id> [--by] # decide
202
221
  obstat deny <id> [--by]
@@ -209,14 +228,34 @@ obstat resume
209
228
  `obstat stop` is checked before policy, so stopping never depends on the policy
210
229
  file still being parseable.
211
230
 
212
- ## What is not here yet
231
+ `obstat check` exits 0 for an allow and 1 for anything else, so a policy can be
232
+ tested in CI — and a policy that does not parse is reported there rather than by
233
+ the next real call.
234
+
235
+ ## Arguments
213
236
 
214
- Arguments are fingerprinted (`sha256:…`), never stored tool arguments carry
215
- credentials and personal data, and a governance log that leaks them is a liability
216
- rather than a control. A per-key allowlist for recording chosen values is the
217
- obvious next step.
237
+ Fingerprinted by default, never stored: tool arguments carry credentials and
238
+ personal data, and a governance log that leaks them is a liability rather than a
239
+ control. Name the ones a human needs and those values are recorded too
240
+
241
+ ```python
242
+ @guard(resource="tool:send_email", record_args=("to",))
243
+ def send_email(to: str, body: str) -> str: ...
244
+ ```
245
+
246
+ ```console
247
+ $ obstat pending
248
+ d41b88f29a43 send_email human:ana tool:send_email 871s left
249
+ to = 'board@example.com'
250
+ ```
251
+
252
+ — because an approver deciding about `sha256:ae32e6…` is deciding about nothing.
253
+ The digest still covers every argument; `body` is in it and nowhere else. Name
254
+ identifiers, not payloads.
255
+
256
+ ## What is not here yet
218
257
 
219
- Also absent, deliberately: retention and rotation of the log, Slack and webhook
258
+ Deliberately absent: retention and rotation of the log, Slack and webhook
220
259
  approval channels, a policy for where a result may be *sent*, and anything that
221
260
  talks to a cloud. Those belong at the edges, and the edges should be adapters
222
261
  rather than dependencies.
@@ -229,6 +268,7 @@ rather than dependencies.
229
268
  | `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
230
269
  | `OBSTAT_DB` | `.obstat/approvals.db` |
231
270
  | `OBSTAT_HALT` | `.obstat/halt` |
271
+ | `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
232
272
 
233
273
  Read at call time, never at import. A library that raises on import is a library
234
274
  you cannot try.
@@ -26,6 +26,11 @@ is still weak.
26
26
 
27
27
  ## 60 seconds
28
28
 
29
+ ```console
30
+ $ obstat init
31
+ wrote obstat.toml — everything is denied until you uncomment a rule
32
+ ```
33
+
29
34
  `obstat.toml`:
30
35
 
31
36
  ```toml
@@ -152,6 +157,18 @@ permission, and a missing policy file is an error rather than an implicit allow.
152
157
  Patterns are globs. The file is re-read when it changes, so editing policy does
153
158
  not need a restart.
154
159
 
160
+ They are matched **case-sensitively**, against an id built from arguments the
161
+ caller sent. If your namespace is not case-sensitive — Jira keys, most
162
+ filesystems — a rule written for `jira_issue:SEC-*` will not cover `sec-1`, and
163
+ nothing will tell you. Normalise the id where you build it:
164
+
165
+ ```python
166
+ @guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}")
167
+ ```
168
+
169
+ That is what the callable form is for; [§3.3](docs/obstat-spec.md#33-resource-resolution)
170
+ has the whole story, including why obstat does not fold case for you.
171
+
155
172
  ## The order
156
173
 
157
174
  ```
@@ -176,6 +193,8 @@ reads "authorised, outcome unknown", which is the honest state; paying for a sec
176
193
  ## Operator commands
177
194
 
178
195
  ```console
196
+ obstat init # a starter policy; refuses to overwrite one
197
+ obstat check <tool> [res] # what the policy would decide, without a call
179
198
  obstat pending # approvals waiting on a human
180
199
  obstat approve <id> [--by] # decide
181
200
  obstat deny <id> [--by]
@@ -188,14 +207,34 @@ obstat resume
188
207
  `obstat stop` is checked before policy, so stopping never depends on the policy
189
208
  file still being parseable.
190
209
 
191
- ## What is not here yet
210
+ `obstat check` exits 0 for an allow and 1 for anything else, so a policy can be
211
+ tested in CI — and a policy that does not parse is reported there rather than by
212
+ the next real call.
213
+
214
+ ## Arguments
192
215
 
193
- Arguments are fingerprinted (`sha256:…`), never stored tool arguments carry
194
- credentials and personal data, and a governance log that leaks them is a liability
195
- rather than a control. A per-key allowlist for recording chosen values is the
196
- obvious next step.
216
+ Fingerprinted by default, never stored: tool arguments carry credentials and
217
+ personal data, and a governance log that leaks them is a liability rather than a
218
+ control. Name the ones a human needs and those values are recorded too
219
+
220
+ ```python
221
+ @guard(resource="tool:send_email", record_args=("to",))
222
+ def send_email(to: str, body: str) -> str: ...
223
+ ```
224
+
225
+ ```console
226
+ $ obstat pending
227
+ d41b88f29a43 send_email human:ana tool:send_email 871s left
228
+ to = 'board@example.com'
229
+ ```
230
+
231
+ — because an approver deciding about `sha256:ae32e6…` is deciding about nothing.
232
+ The digest still covers every argument; `body` is in it and nowhere else. Name
233
+ identifiers, not payloads.
234
+
235
+ ## What is not here yet
197
236
 
198
- Also absent, deliberately: retention and rotation of the log, Slack and webhook
237
+ Deliberately absent: retention and rotation of the log, Slack and webhook
199
238
  approval channels, a policy for where a result may be *sent*, and anything that
200
239
  talks to a cloud. Those belong at the edges, and the edges should be adapters
201
240
  rather than dependencies.
@@ -208,6 +247,7 @@ rather than dependencies.
208
247
  | `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
209
248
  | `OBSTAT_DB` | `.obstat/approvals.db` |
210
249
  | `OBSTAT_HALT` | `.obstat/halt` |
250
+ | `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
211
251
 
212
252
  Read at call time, never at import. A library that raises on import is a library
213
253
  you cannot try.
@@ -1,7 +1,7 @@
1
1
  # obstat — specification
2
2
 
3
- Normative. Where this document and the code disagree, one of them is a bug; say
4
- which in an issue.
3
+ Normative, §10 excepted. Where this document and the code disagree, one of them
4
+ is a bug; say which in an issue.
5
5
 
6
6
  The claim obstat makes is narrow: **a tool call leaves a written decision before
7
7
  it runs.** Everything below exists to make that claim true without an identity
@@ -109,6 +109,28 @@ effect on the next call rather than the next restart. The cost is one `stat()`
109
109
  per call, which is cheaper than the class of incident where someone tightened a
110
110
  rule, saw no change, and concluded the tightening was wrong.
111
111
 
112
+ ### 2.3 Writing one, and asking what it would do
113
+
114
+ ```console
115
+ obstat init # a starter obstat.toml
116
+ obstat check delete_document doc:q3-report # deny (rule 0)
117
+ obstat check read_doc --subject human:ana # allow (rule 1)
118
+ ```
119
+
120
+ `init` writes every rule commented out and one live `deny`, so a policy nobody
121
+ finished editing permits nothing. It refuses to overwrite an existing file: that
122
+ file is the only thing standing between an agent and every guarded tool.
123
+
124
+ `check` evaluates §2 against the file on disk and prints the effect with the rule
125
+ that produced it. The resource argument is optional and defaults to
126
+ `tool:<name>`, the same default §3.3 applies. Exit status is 0 for `allow` and 1
127
+ for anything else, including a policy that does not parse — which is otherwise
128
+ discovered by the next real call, in front of a real agent.
129
+
130
+ It writes no record, resolves no resource and touches no approval. It answers
131
+ what the policy *would* say, which is the question §2.2's reload leaves a reader
132
+ unable to ask.
133
+
112
134
  ---
113
135
 
114
136
  ## 3. `@guard`
@@ -118,6 +140,7 @@ def guard(
118
140
  *,
119
141
  resource: str | Callable[[dict[str, Any]], str] | None = None,
120
142
  tool: str | None = None,
143
+ record_args: tuple[str, ...] = (),
121
144
  ) -> Callable[[F], F]: ...
122
145
  ```
123
146
 
@@ -149,8 +172,10 @@ Normative. Each step's failure is terminal.
149
172
  **Step 6 before step 7 is the whole point.** `record.decision()` returns only
150
173
  after `write` and `fsync` — and, on the write that creates the log file, an
151
174
  `fsync` of the containing directory too, since a synced file whose directory
152
- entry is not synced can survive a crash with nothing pointing at it. If the
153
- process dies between 6 and 7, the
175
+ entry is not synced can survive a crash with nothing pointing at it. That
176
+ directory sync opens a directory as a file descriptor, which POSIX allows and
177
+ Windows does not; where it cannot be opened it is skipped, and §8 says what that
178
+ costs. If the process dies between 6 and 7, the
154
179
  record says `allow` and no outcome follows, which reads as "authorised, did not
155
180
  complete" — a state an examiner can act on. The reverse ordering produces
156
181
  "executed, no idea whether it was allowed", which is the state that costs an
@@ -194,6 +219,37 @@ policy needs to distinguish. A template that raises (`KeyError`, `IndexError`,
194
219
  `AttributeError`, `TypeError`) denies with the resource recorded as `unresolved`.
195
220
  It never falls back to a wildcard.
196
221
 
222
+ #### The id is caller-controlled text, matched byte for byte
223
+
224
+ A template interpolates arguments the caller supplied, and §2 matches the result
225
+ with a case-sensitive glob. Where the system behind the tool treats two spellings
226
+ as one object, the policy does not:
227
+
228
+ ```toml
229
+ [[rule]]
230
+ resource = "jira_issue:SEC-*"
231
+ effect = "deny"
232
+ ```
233
+
234
+ `transition_issue("SEC-1", …)` is denied. `transition_issue("sec-1", …)` is
235
+ allowed, and Jira resolves both to the same issue.
236
+
237
+ obstat does not fix this centrally, and folding case for everyone would be worse
238
+ than the problem: whether `ACME-1` and `acme-1` name one thing is a fact about
239
+ the caller's namespace, and case-folding one that is genuinely case-sensitive
240
+ would silently widen every `allow` rule to cover objects it was never written
241
+ for. **Normalising is the tool author's job.** It is what the callable form
242
+ exists for:
243
+
244
+ ```python
245
+ @guard(resource=lambda a: f"jira_issue:{a['issue_key'].upper()}")
246
+ ```
247
+
248
+ Normalising the id does not loosen the approval binding. §5.2 binds an approval
249
+ to `args_digest`, which is taken over the raw arguments, so an approval granted
250
+ for `SEC-1` is refused when it is retried as `sec-1` — `approval_mismatch` —
251
+ even though both produce the same resource.
252
+
197
253
  ### 3.4 The stop file
198
254
 
199
255
  Every other control answers "may this call proceed". This one answers "is
@@ -245,10 +301,20 @@ The wrapper's `__signature__` is the wrapped function's, minus `subject`, plus:
245
301
  obstat_approval_id: str | None = None # keyword-only
246
302
  ```
247
303
 
304
+ The **return annotation is widened** to `R | dict[str, Any]`, where `R` is what
305
+ the body declared. A tool declaring `-> str` is telling the truth about its own
306
+ result and not about its wrapper's: policy may send the call to a human and
307
+ obstat returns §5.1's payload instead. A server validates a tool's result against
308
+ this annotation, so without the widening an approval reaches the client as a
309
+ protocol error — the exact outcome §5.1 returns rather than raises to avoid. An
310
+ undeclared return is left undeclared; there is nothing to validate against.
311
+
248
312
  MCP servers generate their tool schema from that signature, so a client sees the
249
313
  approval argument — it needs to, for §5.2 — and cannot see `subject`. Verified
250
- against the MCP SDK in the example server: `delete_document` advertises
251
- `['doc_id', 'obstat_approval_id']`.
314
+ against the MCP SDK by calling through it, not by reading the signature:
315
+ `delete_document` advertises `['doc_id', 'obstat_approval_id']`, an allowed call
316
+ returns its result, and an approval-required call returns the §5.1 payload with
317
+ `is_error` false.
252
318
 
253
319
  ---
254
320
 
@@ -264,8 +330,8 @@ Policy said `approve` and no usable approval was supplied. obstat:
264
330
  1. Derives the approval id: `sha256(tool | subject | resource | args_digest)[:12]`.
265
331
  2. Writes a decision record with `effect: "approval_required"`, naming that id.
266
332
  3. Inserts a `pending` row bound to `(tool, subject, resource, args_digest)` with
267
- `expires = now + 900s` — **or rejoins** the row already there, if one is still
268
- `pending` or `approved` and unexpired.
333
+ `expires = now + OBSTAT_APPROVAL_TTL` (§7, 900s by default) — **or rejoins**
334
+ the row already there, if one is still `pending` or `approved` and unexpired.
269
335
  4. Returns — **does not raise** — this value:
270
336
 
271
337
  ```python
@@ -280,9 +346,21 @@ Policy said `approve` and no usable approval was supplied. obstat:
280
346
  ```
281
347
 
282
348
  A return rather than an error, so the agent can reason about it and tell the user
283
- what it is waiting for instead of treating a working control as a failure.
284
- `waiting` is `True` when this rejoined an existing request, which is how an agent
285
- distinguishes "asked" from "asked again".
349
+ what it is waiting for instead of treating a working control as a failure. This
350
+ is why §4.2 widens the advertised return: a server that validates results against
351
+ the declared type would otherwise turn this payload back into the error it exists
352
+ not to be. `waiting` is `True` when this rejoined an existing request, which is
353
+ how an agent distinguishes "asked" from "asked again".
354
+
355
+ The approvals table holds the argument *digest*, so `obstat pending` reads what
356
+ the call was for off the record named in step 2 — anything the tool recorded
357
+ under §6.1 is printed beneath the row:
358
+
359
+ ```console
360
+ $ obstat pending
361
+ d41b88f29a43 send_email human:ana tool:send_email 871s left
362
+ to = 'board@example.com'
363
+ ```
286
364
 
287
365
  **The id is derived, not invented.** A random id would mean every retry opens
288
366
  another approval, so an agent polling a slow human produces a queue of identical
@@ -379,7 +457,7 @@ fragment, so one failed write costs one record instead of two — `obstat verify
379
457
  reports the fragment and everything after it still reads.
380
458
 
381
459
  ```json
382
- {"schema": 3,
460
+ {"schema": 4,
383
461
  "id": "6430f1a38f8e470a898ca9a116dfb4cc",
384
462
  "ts": 1785545412.241792,
385
463
  "phase": "decision",
@@ -401,27 +479,61 @@ reports the fragment and everything after it still reads.
401
479
  the policy file, so a record points at the line that decided it. `prev` and
402
480
  `hash` are the chain (§6.3).
403
481
 
404
- Schema 2 added `prev` and `hash`; schema 3 added `code`. Records written at
405
- schema 1 have no chain fields and verify as *unverifiable* rather than as damaged
406
- — a log that predates the chain is not evidence of tampering.
482
+ Schema 2 added `prev` and `hash`; schema 3 added `code`; schema 4 added
483
+ `args_recorded` (§6.1). Records written at schema 1 have no chain fields and
484
+ verify as *unverifiable* rather than as damaged — a log that predates the chain is
485
+ not evidence of tampering.
407
486
 
408
487
  `via` (§1) is present on an `allow` record only when the subject carries a
409
488
  delegation chain: `"via": ["human:ana"]`. It is omitted rather than written empty,
410
489
  because `"via": []` on every record of every deployment that never delegates is
411
- noise in the thing an examiner has to read.
490
+ noise in the thing an examiner has to read. `args_recorded` is omitted on the
491
+ same grounds.
412
492
 
413
- ### 6.1 Arguments are fingerprinted, not stored
493
+ ### 6.1 Arguments are fingerprinted, and named values are recorded
414
494
 
415
495
  `args` is `sha256:` over the canonical JSON of the bound arguments. The values
416
- are deliberately absent: tool arguments carry credentials, personal data and free
496
+ are absent by default: tool arguments carry credentials, personal data and free
417
497
  text, and a governance log that leaks them is a liability rather than a control.
418
498
  The digest is enough to prove the call that executed is the call that was
419
499
  approved, which is what the record is for.
420
500
 
501
+ A digest is not enough for the *other* reader. An approver deciding about
502
+ `sha256:ae32e6…` is deciding about nothing, and the resource id only sometimes
503
+ carries the answer — `doc:q3-report` does, `tool:send_email` does not. So a tool
504
+ may name parameters whose values are recorded:
505
+
506
+ ```python
507
+ @guard(record_args=("to", "issue_key"))
508
+ ```
509
+
510
+ ```json
511
+ "args_recorded": {"to": "ana@example.com", "issue_key": "ACME-14"}
512
+ ```
513
+
514
+ Normative:
515
+
516
+ - The allowlist is **explicit and per tool**. There is no wildcard and no
517
+ "record everything" switch — the absent default is the safe one, and a
518
+ deployment that wants values names them.
519
+ - Naming a parameter the tool does not have is a `TypeError` **at decoration**.
520
+ A name that matched nothing would record nothing and say nothing about it,
521
+ which is the same quiet widening §2 refuses from a typo'd policy key.
522
+ - `args` still covers **every** argument. Recorded values are written beside the
523
+ digest, never instead of it, so the binding an approval rests on (§5.2) is
524
+ unchanged by what anyone chose to display.
525
+ - The field is omitted when nothing was named, on the §6 grounds `via` is.
526
+ - It appears on `allow`, `deny` and `approval_required` records alike: an
527
+ examiner asking what was refused deserves the same answer as an approver
528
+ asking what is waiting.
529
+
530
+ Name identifiers, not payloads. The point is that a human can tell which issue,
531
+ which document, which recipient — not that the log holds a copy of the message.
532
+
421
533
  ### 6.2 Outcome records
422
534
 
423
535
  ```json
424
- {"schema": 3, "id": "<same id>", "ts": …, "phase": "outcome", "ok": true, "error": null,
536
+ {"schema": 4, "id": "<same id>", "ts": …, "phase": "outcome", "ok": true, "error": null,
425
537
  "prev": "…", "hash": "…"}
426
538
  ```
427
539
 
@@ -523,6 +635,12 @@ is a library nobody can try.
523
635
  | `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
524
636
  | `OBSTAT_DB` | `.obstat/approvals.db` |
525
637
  | `OBSTAT_HALT` | `.obstat/halt` |
638
+ | `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
639
+
640
+ A TTL that is not a positive number raises rather than falling back to the
641
+ default. An approval window of zero turns every `approve` rule into a call that
642
+ asks, expires and asks again, and a silent fallback is how that gets diagnosed as
643
+ obstat being broken instead of as a typo.
526
644
 
527
645
  ---
528
646
 
@@ -530,10 +648,23 @@ is a library nobody can try.
530
648
 
531
649
  Ranked by how much they weaken the claim in the first paragraph.
532
650
 
651
+ **A resource id is caller-controlled text and nothing normalises it.** §3.3. A
652
+ policy written against `jira_issue:SEC-*` says nothing about `sec-1`, and the
653
+ cost of noticing is a denial that silently did not happen. The callable form of
654
+ `resource` is the entire answer today, which means the answer is applied only
655
+ where a tool author thought of the question. What is missing is not a fold — that
656
+ would be wrong for case-sensitive namespaces — but a way for a *policy* to say
657
+ which of its rules describe a namespace that is case-insensitive.
658
+
533
659
  **No approver authority.** §5.4. Anything beyond a single operator needs the
534
660
  approver to be authenticated and distinct from the requester.
535
661
 
536
- **The stop file is all-or-nothing.** §3.4.
662
+ **An approver sees only what the tool chose to show.** §6.1 gives a tool
663
+ `record_args`, and `obstat pending` prints it — but a tool that names nothing
664
+ still sends a human a digest and a resource id to decide on. Nothing warns an
665
+ operator that a rule with `effect = "approve"` sits in front of a tool that
666
+ records no values, which is the configuration where the approval is a rubber
667
+ stamp.
537
668
 
538
669
  **The chain is unkeyed, and nothing anchors its head.** §6.3 makes an edit or a
539
670
  deletion visible, which is where most of the value is — but anyone who can write
@@ -542,8 +673,19 @@ dangling. The answers are a head published where the log's owner cannot reach it
542
673
  or append-only storage. Neither is here, so this is tamper-evidence and not proof
543
674
  against the operator.
544
675
 
676
+ **The stop file is all-or-nothing.** §3.4.
677
+
545
678
  **No retention or rotation on the log.** It grows without bound, and nothing says
546
- how long a record must be kept.
679
+ how long a record must be kept. Rotation has to carry the chain across files as
680
+ well, or every new file begins at `prev: null` and a deletion at the seam is
681
+ indistinguishable from a rollover.
682
+
683
+ **The directory sync does not happen on Windows.** It opens a directory as a file
684
+ descriptor, which Windows refuses, so there it is skipped rather than performed
685
+ (§3.1) and the guarantee it exists for — that the log's own directory entry
686
+ survives a crash — is unavailable on that platform. The record's `fsync` still
687
+ happens, so this costs the *first* write of a new log file and nothing after it.
688
+ A platform that can open the directory and then fails to sync it still raises.
547
689
 
548
690
  **No egress control.** Nothing asks where a result is allowed to go, which is the
549
691
  control that matters for tools that send mail or post to channels.
@@ -556,10 +698,14 @@ adapters, not dependencies.
556
698
  ## 9. Required tests
557
699
 
558
700
  These encode the claims above; `tests/test_guard.py` is the current
559
- implementation of them.
701
+ implementation of them, with §2.3's two commands in `tests/test_cli.py`.
560
702
 
561
703
  | Property | Test |
562
704
  |---|---|
705
+ | a starter policy parses and permits nothing | `test_init_writes_a_policy_that_permits_nothing` |
706
+ | `init` does not overwrite a policy | `test_init_will_not_overwrite_a_policy` |
707
+ | `check` names the rule that decided | `test_check_names_the_rule_that_decided` |
708
+ | a broken policy is reported, not raised | `test_check_reports_a_broken_policy_instead_of_raising` |
563
709
  | the record is durable before the body runs | reads the log from inside the body and asserts its own decision is already there |
564
710
  | an outcome follows a decision, sharing its id | `test_outcome_is_recorded_after` |
565
711
  | a failing body still leaves both records | `test_a_failing_body_still_leaves_both_records` |
@@ -568,14 +714,27 @@ implementation of them.
568
714
  | a caller cannot name itself | `test_a_caller_cannot_supply_its_own_subject` |
569
715
  | a colliding `subject` parameter is refused at decoration | `test_a_subject_that_would_collide_is_refused_at_decoration` |
570
716
  | a delegation chain reaches the record | `test_a_delegation_chain_reaches_the_record` |
717
+ | the approval window is configurable, and a useless one raises | `test_the_approval_window_is_configurable` |
571
718
  | an edited record is caught | `TestChain::test_an_edited_record_is_caught` |
572
719
  | a removed record is caught | `TestChain::test_a_removed_record_is_caught` |
573
720
  | recomputing one hash does not hide the edit | `TestChain::test_a_reused_hash_does_not_hide_an_edit` |
574
721
  | a torn line does not swallow the next record | `TestChain::test_a_torn_line_does_not_swallow_the_next_record` |
722
+ | concurrent processes interleave records without splitting one | `TestConcurrency::test_two_processes_write_one_log` |
723
+ | within one process the chain stays a line | `TestConcurrency::test_threads_do_not_split_a_record` |
724
+ | a forked chain verifies | `TestConcurrency::test_a_forked_chain_still_verifies` |
575
725
  | `subject` unadvertised, approval id advertised | `test_injected_subject_is_not_advertised…` |
726
+ | an approval is a return value through a real MCP server | `test_an_approval_survives_a_real_mcp_server` |
576
727
  | the resource comes from the arguments | `test_resource_comes_from_the_arguments` |
728
+ | a template matches only the spelling it was given | `test_a_template_matches_only_the_spelling_it_was_given` |
729
+ | a callable resource normalises what policy matches | `test_a_callable_resource_normalises_what_policy_matches` |
730
+ | an unknown effect is refused | `test_an_unknown_effect_is_refused` |
577
731
  | an unresolvable resource denies | `test_an_unresolvable_resource_denies` |
578
732
  | arguments are fingerprinted, not stored | `test_arguments_are_fingerprinted_not_stored` |
733
+ | only the named arguments are recorded | `test_only_the_named_arguments_are_recorded` |
734
+ | nothing is recorded unless it was named | `test_nothing_is_recorded_unless_it_was_named` |
735
+ | an argument that does not exist is refused at decoration | `test_recording_an_argument_that_does_not_exist_is_refused_at_decoration` |
736
+ | a denial records the named arguments | `test_a_denial_records_the_named_arguments` |
737
+ | an approver can see what they are approving | `test_pending_shows_what_is_being_approved` |
579
738
  | the stop file denies, and lifting it restores | `test_the_stop_file_denies_everything` |
580
739
  | approval is two-phase and does not run early | `TestApproval::test_first_call_asks_and_does_not_run` |
581
740
  | approval is single-use | `TestApproval::test_retry_after_approval_runs_once` |
@@ -585,3 +744,50 @@ implementation of them.
585
744
  | approval does not travel to another call | `TestApproval::test_an_approval_does_not_travel_to_another_call` |
586
745
  | a denied or invented approval denies | `TestApproval::test_a_denied_approval_denies`, `…invented_approval_id…` |
587
746
  | async tools take the same path | `test_async_tools_go_through_the_same_gate` |
747
+
748
+ ---
749
+
750
+ ## 10. Direction
751
+
752
+ **Not normative. Nothing in this section is implemented**, and code that
753
+ disagrees with it is not a bug. It is here so a reader can tell a missing feature
754
+ from an undecided one: §8 is what is weak, this is what is next.
755
+
756
+ **Shadow mode, and a policy written from traffic.** Decisions recorded without
757
+ being enforced, so obstat can be installed in front of a live server for a week
758
+ before it denies anything — then `obstat suggest` reads the log back and emits
759
+ candidate rules. Policy authoring is the barrier to adoption, and an empty file
760
+ is not a starting point.
761
+
762
+ **Counterfactual replay.** `obstat check --policy new.toml --against
763
+ decisions.jsonl`: run decisions already recorded through a proposed policy and
764
+ print only what changes. Answers "I tightened a rule, what breaks" from real
765
+ traffic rather than from imagination, and needs nothing that §2 and §6 do not
766
+ already provide.
767
+
768
+ **An evidence pack.** One command producing what a controls walkthrough asks
769
+ for over a period: every call that required an approval, who decided it and
770
+ when, every denial, and the result of §6.3 across the same range. The log is
771
+ already the input; what is missing is the deliverable.
772
+
773
+ **An anchored head.** §8. Counter-signing the chain head somewhere the log's
774
+ owner cannot reach is the difference between tamper-evidence and
775
+ non-repudiation, and it is the only item here that changes what obstat can
776
+ *claim*.
777
+
778
+ **Result digests.** §6.1 fingerprints the arguments, which proves the call that
779
+ ran is the call that was approved. The same over the return value would say
780
+ something about what came back, and is the groundwork any egress rule needs.
781
+
782
+ **Break-glass with a retrospective.** The stop file's opposite: one call
783
+ permitted against policy, recorded as an override, and listed as outstanding
784
+ until somebody signs it off. That it is visible matters more than that it is
785
+ rare.
786
+
787
+ **The record as a format rather than a library.** §6 is language-agnostic. A
788
+ second implementation writing the same lines, verified by the same rules, would
789
+ make this document the product and the Python package one implementation of it.
790
+
791
+ Also wanted and unremarkable: approval channels beyond the CLI (§8), per-subject
792
+ call budgets as a rule key, and a trace id on the record so it joins traces a
793
+ host already collects.
@@ -8,4 +8,4 @@ from .guard import Denied, Subject, guard, set_subject_resolver
8
8
  from .policy import PolicyError
9
9
 
10
10
  __all__ = ["Denied", "PolicyError", "Subject", "guard", "set_subject_resolver"]
11
- __version__ = "0.2.0"
11
+ __version__ = "0.3.1"