obstat 0.2.0__tar.gz → 0.3.0__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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obstat
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -264,8 +320,8 @@ Policy said `approve` and no usable approval was supplied. obstat:
264
320
  1. Derives the approval id: `sha256(tool | subject | resource | args_digest)[:12]`.
265
321
  2. Writes a decision record with `effect: "approval_required"`, naming that id.
266
322
  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.
323
+ `expires = now + OBSTAT_APPROVAL_TTL` (§7, 900s by default) — **or rejoins**
324
+ the row already there, if one is still `pending` or `approved` and unexpired.
269
325
  4. Returns — **does not raise** — this value:
270
326
 
271
327
  ```python
@@ -284,6 +340,16 @@ what it is waiting for instead of treating a working control as a failure.
284
340
  `waiting` is `True` when this rejoined an existing request, which is how an agent
285
341
  distinguishes "asked" from "asked again".
286
342
 
343
+ The approvals table holds the argument *digest*, so `obstat pending` reads what
344
+ the call was for off the record named in step 2 — anything the tool recorded
345
+ under §6.1 is printed beneath the row:
346
+
347
+ ```console
348
+ $ obstat pending
349
+ d41b88f29a43 send_email human:ana tool:send_email 871s left
350
+ to = 'board@example.com'
351
+ ```
352
+
287
353
  **The id is derived, not invented.** A random id would mean every retry opens
288
354
  another approval, so an agent polling a slow human produces a queue of identical
289
355
  requests for that human to sort out — and the pile-up is worst exactly when
@@ -379,7 +445,7 @@ fragment, so one failed write costs one record instead of two — `obstat verify
379
445
  reports the fragment and everything after it still reads.
380
446
 
381
447
  ```json
382
- {"schema": 3,
448
+ {"schema": 4,
383
449
  "id": "6430f1a38f8e470a898ca9a116dfb4cc",
384
450
  "ts": 1785545412.241792,
385
451
  "phase": "decision",
@@ -401,27 +467,61 @@ reports the fragment and everything after it still reads.
401
467
  the policy file, so a record points at the line that decided it. `prev` and
402
468
  `hash` are the chain (§6.3).
403
469
 
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.
470
+ Schema 2 added `prev` and `hash`; schema 3 added `code`; schema 4 added
471
+ `args_recorded` (§6.1). Records written at schema 1 have no chain fields and
472
+ verify as *unverifiable* rather than as damaged — a log that predates the chain is
473
+ not evidence of tampering.
407
474
 
408
475
  `via` (§1) is present on an `allow` record only when the subject carries a
409
476
  delegation chain: `"via": ["human:ana"]`. It is omitted rather than written empty,
410
477
  because `"via": []` on every record of every deployment that never delegates is
411
- noise in the thing an examiner has to read.
478
+ noise in the thing an examiner has to read. `args_recorded` is omitted on the
479
+ same grounds.
412
480
 
413
- ### 6.1 Arguments are fingerprinted, not stored
481
+ ### 6.1 Arguments are fingerprinted, and named values are recorded
414
482
 
415
483
  `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
484
+ are absent by default: tool arguments carry credentials, personal data and free
417
485
  text, and a governance log that leaks them is a liability rather than a control.
418
486
  The digest is enough to prove the call that executed is the call that was
419
487
  approved, which is what the record is for.
420
488
 
489
+ A digest is not enough for the *other* reader. An approver deciding about
490
+ `sha256:ae32e6…` is deciding about nothing, and the resource id only sometimes
491
+ carries the answer — `doc:q3-report` does, `tool:send_email` does not. So a tool
492
+ may name parameters whose values are recorded:
493
+
494
+ ```python
495
+ @guard(record_args=("to", "issue_key"))
496
+ ```
497
+
498
+ ```json
499
+ "args_recorded": {"to": "ana@example.com", "issue_key": "ACME-14"}
500
+ ```
501
+
502
+ Normative:
503
+
504
+ - The allowlist is **explicit and per tool**. There is no wildcard and no
505
+ "record everything" switch — the absent default is the safe one, and a
506
+ deployment that wants values names them.
507
+ - Naming a parameter the tool does not have is a `TypeError` **at decoration**.
508
+ A name that matched nothing would record nothing and say nothing about it,
509
+ which is the same quiet widening §2 refuses from a typo'd policy key.
510
+ - `args` still covers **every** argument. Recorded values are written beside the
511
+ digest, never instead of it, so the binding an approval rests on (§5.2) is
512
+ unchanged by what anyone chose to display.
513
+ - The field is omitted when nothing was named, on the §6 grounds `via` is.
514
+ - It appears on `allow`, `deny` and `approval_required` records alike: an
515
+ examiner asking what was refused deserves the same answer as an approver
516
+ asking what is waiting.
517
+
518
+ Name identifiers, not payloads. The point is that a human can tell which issue,
519
+ which document, which recipient — not that the log holds a copy of the message.
520
+
421
521
  ### 6.2 Outcome records
422
522
 
423
523
  ```json
424
- {"schema": 3, "id": "<same id>", "ts": …, "phase": "outcome", "ok": true, "error": null,
524
+ {"schema": 4, "id": "<same id>", "ts": …, "phase": "outcome", "ok": true, "error": null,
425
525
  "prev": "…", "hash": "…"}
426
526
  ```
427
527
 
@@ -523,6 +623,12 @@ is a library nobody can try.
523
623
  | `OBSTAT_LOG` | `.obstat/decisions.jsonl` |
524
624
  | `OBSTAT_DB` | `.obstat/approvals.db` |
525
625
  | `OBSTAT_HALT` | `.obstat/halt` |
626
+ | `OBSTAT_APPROVAL_TTL` | `900` (seconds) |
627
+
628
+ A TTL that is not a positive number raises rather than falling back to the
629
+ default. An approval window of zero turns every `approve` rule into a call that
630
+ asks, expires and asks again, and a silent fallback is how that gets diagnosed as
631
+ obstat being broken instead of as a typo.
526
632
 
527
633
  ---
528
634
 
@@ -530,10 +636,23 @@ is a library nobody can try.
530
636
 
531
637
  Ranked by how much they weaken the claim in the first paragraph.
532
638
 
639
+ **A resource id is caller-controlled text and nothing normalises it.** §3.3. A
640
+ policy written against `jira_issue:SEC-*` says nothing about `sec-1`, and the
641
+ cost of noticing is a denial that silently did not happen. The callable form of
642
+ `resource` is the entire answer today, which means the answer is applied only
643
+ where a tool author thought of the question. What is missing is not a fold — that
644
+ would be wrong for case-sensitive namespaces — but a way for a *policy* to say
645
+ which of its rules describe a namespace that is case-insensitive.
646
+
533
647
  **No approver authority.** §5.4. Anything beyond a single operator needs the
534
648
  approver to be authenticated and distinct from the requester.
535
649
 
536
- **The stop file is all-or-nothing.** §3.4.
650
+ **An approver sees only what the tool chose to show.** §6.1 gives a tool
651
+ `record_args`, and `obstat pending` prints it — but a tool that names nothing
652
+ still sends a human a digest and a resource id to decide on. Nothing warns an
653
+ operator that a rule with `effect = "approve"` sits in front of a tool that
654
+ records no values, which is the configuration where the approval is a rubber
655
+ stamp.
537
656
 
538
657
  **The chain is unkeyed, and nothing anchors its head.** §6.3 makes an edit or a
539
658
  deletion visible, which is where most of the value is — but anyone who can write
@@ -542,8 +661,19 @@ dangling. The answers are a head published where the log's owner cannot reach it
542
661
  or append-only storage. Neither is here, so this is tamper-evidence and not proof
543
662
  against the operator.
544
663
 
664
+ **The stop file is all-or-nothing.** §3.4.
665
+
545
666
  **No retention or rotation on the log.** It grows without bound, and nothing says
546
- how long a record must be kept.
667
+ how long a record must be kept. Rotation has to carry the chain across files as
668
+ well, or every new file begins at `prev: null` and a deletion at the seam is
669
+ indistinguishable from a rollover.
670
+
671
+ **The directory sync does not happen on Windows.** It opens a directory as a file
672
+ descriptor, which Windows refuses, so there it is skipped rather than performed
673
+ (§3.1) and the guarantee it exists for — that the log's own directory entry
674
+ survives a crash — is unavailable on that platform. The record's `fsync` still
675
+ happens, so this costs the *first* write of a new log file and nothing after it.
676
+ A platform that can open the directory and then fails to sync it still raises.
547
677
 
548
678
  **No egress control.** Nothing asks where a result is allowed to go, which is the
549
679
  control that matters for tools that send mail or post to channels.
@@ -556,10 +686,14 @@ adapters, not dependencies.
556
686
  ## 9. Required tests
557
687
 
558
688
  These encode the claims above; `tests/test_guard.py` is the current
559
- implementation of them.
689
+ implementation of them, with §2.3's two commands in `tests/test_cli.py`.
560
690
 
561
691
  | Property | Test |
562
692
  |---|---|
693
+ | a starter policy parses and permits nothing | `test_init_writes_a_policy_that_permits_nothing` |
694
+ | `init` does not overwrite a policy | `test_init_will_not_overwrite_a_policy` |
695
+ | `check` names the rule that decided | `test_check_names_the_rule_that_decided` |
696
+ | a broken policy is reported, not raised | `test_check_reports_a_broken_policy_instead_of_raising` |
563
697
  | the record is durable before the body runs | reads the log from inside the body and asserts its own decision is already there |
564
698
  | an outcome follows a decision, sharing its id | `test_outcome_is_recorded_after` |
565
699
  | a failing body still leaves both records | `test_a_failing_body_still_leaves_both_records` |
@@ -568,14 +702,23 @@ implementation of them.
568
702
  | a caller cannot name itself | `test_a_caller_cannot_supply_its_own_subject` |
569
703
  | a colliding `subject` parameter is refused at decoration | `test_a_subject_that_would_collide_is_refused_at_decoration` |
570
704
  | a delegation chain reaches the record | `test_a_delegation_chain_reaches_the_record` |
705
+ | the approval window is configurable, and a useless one raises | `test_the_approval_window_is_configurable` |
571
706
  | an edited record is caught | `TestChain::test_an_edited_record_is_caught` |
572
707
  | a removed record is caught | `TestChain::test_a_removed_record_is_caught` |
573
708
  | recomputing one hash does not hide the edit | `TestChain::test_a_reused_hash_does_not_hide_an_edit` |
574
709
  | a torn line does not swallow the next record | `TestChain::test_a_torn_line_does_not_swallow_the_next_record` |
575
710
  | `subject` unadvertised, approval id advertised | `test_injected_subject_is_not_advertised…` |
576
711
  | the resource comes from the arguments | `test_resource_comes_from_the_arguments` |
712
+ | a template matches only the spelling it was given | `test_a_template_matches_only_the_spelling_it_was_given` |
713
+ | a callable resource normalises what policy matches | `test_a_callable_resource_normalises_what_policy_matches` |
714
+ | an unknown effect is refused | `test_an_unknown_effect_is_refused` |
577
715
  | an unresolvable resource denies | `test_an_unresolvable_resource_denies` |
578
716
  | arguments are fingerprinted, not stored | `test_arguments_are_fingerprinted_not_stored` |
717
+ | only the named arguments are recorded | `test_only_the_named_arguments_are_recorded` |
718
+ | nothing is recorded unless it was named | `test_nothing_is_recorded_unless_it_was_named` |
719
+ | an argument that does not exist is refused at decoration | `test_recording_an_argument_that_does_not_exist_is_refused_at_decoration` |
720
+ | a denial records the named arguments | `test_a_denial_records_the_named_arguments` |
721
+ | an approver can see what they are approving | `test_pending_shows_what_is_being_approved` |
579
722
  | the stop file denies, and lifting it restores | `test_the_stop_file_denies_everything` |
580
723
  | approval is two-phase and does not run early | `TestApproval::test_first_call_asks_and_does_not_run` |
581
724
  | approval is single-use | `TestApproval::test_retry_after_approval_runs_once` |
@@ -585,3 +728,50 @@ implementation of them.
585
728
  | approval does not travel to another call | `TestApproval::test_an_approval_does_not_travel_to_another_call` |
586
729
  | a denied or invented approval denies | `TestApproval::test_a_denied_approval_denies`, `…invented_approval_id…` |
587
730
  | async tools take the same path | `test_async_tools_go_through_the_same_gate` |
731
+
732
+ ---
733
+
734
+ ## 10. Direction
735
+
736
+ **Not normative. Nothing in this section is implemented**, and code that
737
+ disagrees with it is not a bug. It is here so a reader can tell a missing feature
738
+ from an undecided one: §8 is what is weak, this is what is next.
739
+
740
+ **Shadow mode, and a policy written from traffic.** Decisions recorded without
741
+ being enforced, so obstat can be installed in front of a live server for a week
742
+ before it denies anything — then `obstat suggest` reads the log back and emits
743
+ candidate rules. Policy authoring is the barrier to adoption, and an empty file
744
+ is not a starting point.
745
+
746
+ **Counterfactual replay.** `obstat check --policy new.toml --against
747
+ decisions.jsonl`: run decisions already recorded through a proposed policy and
748
+ print only what changes. Answers "I tightened a rule, what breaks" from real
749
+ traffic rather than from imagination, and needs nothing that §2 and §6 do not
750
+ already provide.
751
+
752
+ **An evidence pack.** One command producing what a controls walkthrough asks
753
+ for over a period: every call that required an approval, who decided it and
754
+ when, every denial, and the result of §6.3 across the same range. The log is
755
+ already the input; what is missing is the deliverable.
756
+
757
+ **An anchored head.** §8. Counter-signing the chain head somewhere the log's
758
+ owner cannot reach is the difference between tamper-evidence and
759
+ non-repudiation, and it is the only item here that changes what obstat can
760
+ *claim*.
761
+
762
+ **Result digests.** §6.1 fingerprints the arguments, which proves the call that
763
+ ran is the call that was approved. The same over the return value would say
764
+ something about what came back, and is the groundwork any egress rule needs.
765
+
766
+ **Break-glass with a retrospective.** The stop file's opposite: one call
767
+ permitted against policy, recorded as an override, and listed as outstanding
768
+ until somebody signs it off. That it is visible matters more than that it is
769
+ rare.
770
+
771
+ **The record as a format rather than a library.** §6 is language-agnostic. A
772
+ second implementation writing the same lines, verified by the same rules, would
773
+ make this document the product and the Python package one implementation of it.
774
+
775
+ Also wanted and unremarkable: approval channels beyond the CLI (§8), per-subject
776
+ call budgets as a rule key, and a trace id on the record so it joins traces a
777
+ 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.0"