obstat 0.3.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,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.3.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
@@ -301,10 +301,20 @@ The wrapper's `__signature__` is the wrapped function's, minus `subject`, plus:
301
301
  obstat_approval_id: str | None = None # keyword-only
302
302
  ```
303
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
+
304
312
  MCP servers generate their tool schema from that signature, so a client sees the
305
313
  approval argument — it needs to, for §5.2 — and cannot see `subject`. Verified
306
- against the MCP SDK in the example server: `delete_document` advertises
307
- `['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.
308
318
 
309
319
  ---
310
320
 
@@ -336,9 +346,11 @@ Policy said `approve` and no usable approval was supplied. obstat:
336
346
  ```
337
347
 
338
348
  A return rather than an error, so the agent can reason about it and tell the user
339
- what it is waiting for instead of treating a working control as a failure.
340
- `waiting` is `True` when this rejoined an existing request, which is how an agent
341
- 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".
342
354
 
343
355
  The approvals table holds the argument *digest*, so `obstat pending` reads what
344
356
  the call was for off the record named in step 2 — anything the tool recorded
@@ -707,7 +719,11 @@ implementation of them, with §2.3's two commands in `tests/test_cli.py`.
707
719
  | a removed record is caught | `TestChain::test_a_removed_record_is_caught` |
708
720
  | recomputing one hash does not hide the edit | `TestChain::test_a_reused_hash_does_not_hide_an_edit` |
709
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` |
710
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` |
711
727
  | the resource comes from the arguments | `test_resource_comes_from_the_arguments` |
712
728
  | a template matches only the spelling it was given | `test_a_template_matches_only_the_spelling_it_was_given` |
713
729
  | a callable resource normalises what policy matches | `test_a_callable_resource_normalises_what_policy_matches` |
@@ -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.3.0"
11
+ __version__ = "0.3.1"
@@ -126,12 +126,33 @@ class _Checked:
126
126
  approval_id: str | None
127
127
 
128
128
 
129
+ def _widened_return(returns: Any) -> Any:
130
+ """The advertised return: what the body promises, or the §5.1 payload.
131
+
132
+ A server validates a tool's result against this. A tool annotated `-> str`
133
+ therefore fails when policy sends the call to a human and obstat returns the
134
+ approval payload instead — the control reaches the client as a protocol
135
+ error, which is the opposite of what §5.1 returns rather than raises for.
136
+ """
137
+ if returns is inspect.Signature.empty:
138
+ return returns # nothing declared, so nothing to validate against
139
+ try:
140
+ return returns | dict[str, Any]
141
+ except TypeError:
142
+ # `from __future__ import annotations` leaves the annotation a string,
143
+ # and a string has no `|`. Servers resolve either form the same way.
144
+ return f"{returns} | dict[str, Any]"
145
+
146
+
129
147
  def _public_signature(original: inspect.Signature) -> inspect.Signature:
130
- """The signature callers see: `subject` removed, `obstat_approval_id` added.
148
+ """The signature callers see: `subject` removed, `obstat_approval_id` added,
149
+ and the return widened to admit the approval payload.
131
150
 
132
151
  `subject` is injected by obstat, so leaving it in the advertised schema would
133
152
  invite a client to supply its own. The approval id is the opposite — the
134
- retry protocol needs the caller to send it, so it has to be advertised.
153
+ retry protocol needs the caller to send it, so it has to be advertised. The
154
+ return is widened for the same reason both of those are true: this signature
155
+ describes what the wrapper does, not what the body does.
135
156
  """
136
157
  kept = [p for name, p in original.parameters.items() if name != "subject"]
137
158
  var_keyword = [p for p in kept if p.kind is inspect.Parameter.VAR_KEYWORD]
@@ -142,7 +163,10 @@ def _public_signature(original: inspect.Signature) -> inspect.Signature:
142
163
  default=None,
143
164
  annotation="str | None",
144
165
  )
145
- return original.replace(parameters=[*positional, approval_param, *var_keyword])
166
+ return original.replace(
167
+ parameters=[*positional, approval_param, *var_keyword],
168
+ return_annotation=_widened_return(original.return_annotation),
169
+ )
146
170
 
147
171
 
148
172
  def _resource_for(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "obstat"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "An auditable decision record for agent tool calls. The clearance is written down before the call runs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -10,6 +10,10 @@ from __future__ import annotations
10
10
 
11
11
  import inspect
12
12
  import json
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
13
17
 
14
18
  import pytest
15
19
 
@@ -540,6 +544,164 @@ class TestChain:
540
544
  assert record.verify(log) == ["line 5: not a record"]
541
545
 
542
546
 
547
+ CHILD = """
548
+ import pathlib, sys, time
549
+ from obstat import guard
550
+
551
+
552
+ @guard()
553
+ def touch(n: int) -> int:
554
+ return n
555
+
556
+
557
+ pathlib.Path(sys.argv[1]).write_text("ready")
558
+ go = pathlib.Path(sys.argv[2])
559
+ while not go.exists():
560
+ time.sleep(0.005)
561
+ for i in range({calls}):
562
+ touch(i)
563
+ """
564
+
565
+
566
+ class TestConcurrency:
567
+ """Two writers, one log. §6 says records interleave but never split; §6.3
568
+ says the chain forks across processes and verification tolerates it.
569
+
570
+ Both are claims about what happens when nothing is coordinating the writers,
571
+ which is the state a served tool is in and the state no other test puts it
572
+ in.
573
+ """
574
+
575
+ CALLS = 15
576
+
577
+ def test_threads_do_not_split_a_record(self, workspace):
578
+ """One process, one lock: every record whole, and the chain still a line."""
579
+ workspace(ALLOW_ALL)
580
+
581
+ @guard()
582
+ def touch(n: int) -> int:
583
+ return n
584
+
585
+ threads = [
586
+ threading.Thread(target=lambda: [touch(i) for i in range(self.CALLS)]) for _ in range(4)
587
+ ]
588
+ for thread in threads:
589
+ thread.start()
590
+ for thread in threads:
591
+ thread.join()
592
+
593
+ # read() parses every line, so a split record fails here before anything
594
+ # is asserted about it.
595
+ entries = record.read()
596
+ assert len(entries) == 4 * self.CALLS * 2 # a decision and an outcome each
597
+
598
+ # `_chain_lock` covers hashing and appending together, so within one
599
+ # process the result is a line: one root, and no record followed twice.
600
+ prevs = [entry["prev"] for entry in entries]
601
+ assert prevs.count(None) == 1
602
+ assert len(prevs) == len(set(prevs))
603
+ assert record.verify() == []
604
+
605
+ def test_two_processes_write_one_log(self, workspace, tmp_path):
606
+ """No inter-process lock, so this is O_APPEND and one write() syscall
607
+ doing the work (§6). Released together, because two writers that happen
608
+ to take turns prove nothing."""
609
+ workspace(ALLOW_ALL)
610
+ script = tmp_path / "child.py"
611
+ script.write_text(CHILD.format(calls=self.CALLS), encoding="utf-8")
612
+
613
+ go = tmp_path / "go"
614
+ children = []
615
+ for name in ("a", "b"):
616
+ ready = tmp_path / f"ready-{name}"
617
+ children.append(
618
+ (
619
+ ready,
620
+ subprocess.Popen([sys.executable, str(script), str(ready), str(go)]),
621
+ )
622
+ )
623
+ deadline = time.time() + 30
624
+ while not all(ready.exists() for ready, _ in children):
625
+ assert time.time() < deadline, "a child never started"
626
+ time.sleep(0.01)
627
+ go.write_text("go")
628
+
629
+ for _, child in children:
630
+ assert child.wait(timeout=60) == 0
631
+
632
+ entries = record.read()
633
+ assert len(entries) == 2 * self.CALLS * 2
634
+ assert record.verify() == []
635
+
636
+ def test_a_forked_chain_still_verifies(self, workspace):
637
+ """What a second process does, done deliberately: carry on from the
638
+ record you last saw, not from the one that is now on the end.
639
+
640
+ Reproduced in-process because two real processes might take turns and
641
+ never fork at all, and a test that only sometimes tests the thing is
642
+ worse than no test.
643
+ """
644
+ workspace(ALLOW_ALL)
645
+
646
+ @guard()
647
+ def touch(what: str) -> str:
648
+ return what
649
+
650
+ touch("first")
651
+ branch = record._chain # what another process would still be holding
652
+ touch("second")
653
+ record._chain = branch
654
+ touch("third")
655
+
656
+ prevs = [entry["prev"] for entry in record.read()]
657
+ assert len(prevs) != len(set(prevs)), "nothing forked, so nothing was tested"
658
+ # Every `prev` still names a record earlier in the file, which is all
659
+ # verification asks of it — a fork is not damage.
660
+ assert record.verify() == []
661
+
662
+
663
+ async def test_an_approval_survives_a_real_mcp_server(workspace):
664
+ """Through the SDK, not past it (§4.2).
665
+
666
+ Every other test calls the decorated function directly, which is exactly how
667
+ a server validating the tool's result against its return annotation went
668
+ unnoticed: a tool declaring `-> str` raised at the client the moment policy
669
+ sent a call to a human, turning the control §5.1 designed as a return value
670
+ into a protocol error.
671
+ """
672
+ server = pytest.importorskip("mcp.server", reason="mcp is an optional extra")
673
+ workspace('[[rule]]\ntool = "read_*"\neffect = "allow"\n[[rule]]\neffect = "approve"\n')
674
+
675
+ mcp = server.MCPServer("test")
676
+
677
+ @mcp.tool()
678
+ @guard()
679
+ def read_thing(doc_id: str) -> str:
680
+ return f"contents of {doc_id}"
681
+
682
+ @mcp.tool()
683
+ @guard()
684
+ def delete_thing(doc_id: str) -> str: # pragma: no cover - waits for a human
685
+ return f"{doc_id} deleted"
686
+
687
+ advertised = await mcp.list_tools()
688
+ assert {tool.name for tool in advertised} == {"read_thing", "delete_thing"}
689
+
690
+ allowed = await mcp.call_tool("read_thing", {"doc_id": "q3"})
691
+ assert allowed.is_error is False
692
+ assert allowed.content[0].text == "contents of q3"
693
+
694
+ # The one that used to raise.
695
+ asked = await mcp.call_tool("delete_thing", {"doc_id": "q3"})
696
+ assert asked.is_error is False
697
+ assert json.loads(asked.content[0].text)["obstat"] == "approval_required"
698
+
699
+ assert [entry["effect"] for entry in record.read() if entry["phase"] == "decision"] == [
700
+ "allow",
701
+ "approval_required",
702
+ ]
703
+
704
+
543
705
  async def test_async_tools_go_through_the_same_gate(workspace):
544
706
  workspace('[[rule]]\ntool = "fetch"\neffect = "allow"\n[[rule]]\neffect = "deny"\n')
545
707
 
@@ -335,7 +335,7 @@ wheels = [
335
335
 
336
336
  [[package]]
337
337
  name = "obstat"
338
- version = "0.3.0"
338
+ version = "0.3.1"
339
339
  source = { editable = "." }
340
340
 
341
341
  [package.optional-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes