results-cli 0.4.2__tar.gz → 0.5.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.
Files changed (29) hide show
  1. {results_cli-0.4.2 → results_cli-0.5.0}/PKG-INFO +28 -2
  2. {results_cli-0.4.2 → results_cli-0.5.0}/README.md +26 -0
  3. {results_cli-0.4.2 → results_cli-0.5.0}/plugin/.claude-plugin/plugin.json +1 -1
  4. {results_cli-0.4.2 → results_cli-0.5.0}/pyproject.toml +2 -2
  5. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/audit.py +55 -2
  6. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/cli.py +10 -0
  7. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/ledger.py +41 -22
  8. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/record.py +16 -0
  9. results_cli-0.5.0/src/results/stamps.py +125 -0
  10. {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_cli.py +22 -0
  11. results_cli-0.5.0/tests/test_concurrency.py +148 -0
  12. results_cli-0.5.0/tests/test_timestamp.py +142 -0
  13. {results_cli-0.4.2 → results_cli-0.5.0}/.gitignore +0 -0
  14. {results_cli-0.4.2 → results_cli-0.5.0}/LICENSE +0 -0
  15. {results_cli-0.4.2 → results_cli-0.5.0}/plugin/README.md +0 -0
  16. {results_cli-0.4.2 → results_cli-0.5.0}/plugin/commands/results-check.md +0 -0
  17. {results_cli-0.4.2 → results_cli-0.5.0}/plugin/hooks/hooks.json +0 -0
  18. {results_cli-0.4.2 → results_cli-0.5.0}/plugin/hooks/unbound_numbers.py +0 -0
  19. {results_cli-0.4.2 → results_cli-0.5.0}/plugin/skills/results/SKILL.md +0 -0
  20. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/__init__.py +0 -0
  21. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/manuscript.py +0 -0
  22. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/paths.py +0 -0
  23. {results_cli-0.4.2 → results_cli-0.5.0}/src/results/timeline.py +0 -0
  24. {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_cli_help.py +0 -0
  25. {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_coverage_and_hook.py +0 -0
  26. {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_ledger.py +0 -0
  27. {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_manuscript.py +0 -0
  28. {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_tamper.py +0 -0
  29. {results_cli-0.4.2 → results_cli-0.5.0}/tests/test_timeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: results-cli
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Seal a run, record what it produced, and verify the chain
5
5
  Project-URL: Changelog, https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md
6
6
  Project-URL: Homepage, https://github.com/elliottower/reproducible-science
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Python: >=3.11
19
- Requires-Dist: provenance-core<0.5,>=0.4
19
+ Requires-Dist: provenance-core[anchor]<0.6,>=0.5
20
20
  Description-Content-Type: text/markdown
21
21
 
22
22
  # results
@@ -77,6 +77,7 @@ all checks passed.
77
77
  | `results run <file>...` | Record outputs after a run |
78
78
  | `results claim <text>` | Bind a manuscript claim to a run |
79
79
  | `results verify` | Check the ledger chain and every hash it names |
80
+ | `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
80
81
 
81
82
  ## The chain
82
83
 
@@ -119,6 +120,31 @@ Append-only JSONL in `.results/ledger.jsonl`. Each line is hash-chained to the p
119
120
  or inserting a line breaks the chain. `git diff` shows what changed; `results verify` checks
120
121
  whether it should have.
121
122
 
123
+ `results init` writes a `.results/.gitignore` that ignores only the lock files, so the ledger and
124
+ its anchor are committed with the project. A ledger on one disk is a record nobody else can
125
+ check. What is committed is public with the repository, so a run's `note` is written as a commit
126
+ message would be.
127
+
128
+ ## Timestamp
129
+
130
+ The chain catches a line edited by hand. It cannot catch the ledger and its anchor rewritten
131
+ together, because whoever can write one can write the other. `results timestamp` sends the head
132
+ to the [OpenTimestamps](https://opentimestamps.org) calendars, which commit it into a Bitcoin
133
+ block within a few hours, and keeps the proof under `.results/timestamps/`. Each line names the
134
+ hash of the one before it, so a proof of the head dates every earlier event too.
135
+
136
+ ```text
137
+ events 1–153 existed by Bitcoin block 915004, 2026-10-03 16:12 UTC
138
+ ```
139
+
140
+ `results verify` reads the proofs with no network. A proof whose head no longer matches the
141
+ chain at its length means the ledger was rewritten after it was stamped:
142
+
143
+ ```text
144
+ TIMESTAMP CONTRADICTS THE CHAIN — the ledger was rewritten after it was stamped
145
+ 000153-3f9c2a1b7d4e5f60.ots dates event 153 as 3f9c2a1b7d4e5f60…, and the ledger's event 153 is 81d0…
146
+ ```
147
+
122
148
  ## Claude Code
123
149
 
124
150
  `plugin/` is a Claude Code plugin. Three surfaces, because each catches a different failure:
@@ -56,6 +56,7 @@ all checks passed.
56
56
  | `results run <file>...` | Record outputs after a run |
57
57
  | `results claim <text>` | Bind a manuscript claim to a run |
58
58
  | `results verify` | Check the ledger chain and every hash it names |
59
+ | `results timestamp` | Date the ledger's head outside the repository, and check earlier dates |
59
60
 
60
61
  ## The chain
61
62
 
@@ -98,6 +99,31 @@ Append-only JSONL in `.results/ledger.jsonl`. Each line is hash-chained to the p
98
99
  or inserting a line breaks the chain. `git diff` shows what changed; `results verify` checks
99
100
  whether it should have.
100
101
 
102
+ `results init` writes a `.results/.gitignore` that ignores only the lock files, so the ledger and
103
+ its anchor are committed with the project. A ledger on one disk is a record nobody else can
104
+ check. What is committed is public with the repository, so a run's `note` is written as a commit
105
+ message would be.
106
+
107
+ ## Timestamp
108
+
109
+ The chain catches a line edited by hand. It cannot catch the ledger and its anchor rewritten
110
+ together, because whoever can write one can write the other. `results timestamp` sends the head
111
+ to the [OpenTimestamps](https://opentimestamps.org) calendars, which commit it into a Bitcoin
112
+ block within a few hours, and keeps the proof under `.results/timestamps/`. Each line names the
113
+ hash of the one before it, so a proof of the head dates every earlier event too.
114
+
115
+ ```text
116
+ events 1–153 existed by Bitcoin block 915004, 2026-10-03 16:12 UTC
117
+ ```
118
+
119
+ `results verify` reads the proofs with no network. A proof whose head no longer matches the
120
+ chain at its length means the ledger was rewritten after it was stamped:
121
+
122
+ ```text
123
+ TIMESTAMP CONTRADICTS THE CHAIN — the ledger was rewritten after it was stamped
124
+ 000153-3f9c2a1b7d4e5f60.ots dates event 153 as 3f9c2a1b7d4e5f60…, and the ledger's event 153 is 81d0…
125
+ ```
126
+
101
127
  ## Claude Code
102
128
 
103
129
  `plugin/` is a Claude Code plugin. Three surfaces, because each catches a different failure:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "results",
3
3
  "description": "Seal a run's inputs, record what it produced, bind every number in a manuscript to the run behind it as it is written, and verify the chain",
4
- "version": "0.4.2",
4
+ "version": "0.5.0",
5
5
  "author": {
6
6
  "name": "Elliot Tower",
7
7
  "email": "elliot@elliottower.ai"
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
4
4
 
5
5
  [project]
6
6
  name = "results-cli"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "Seal a run, record what it produced, and verify the chain"
9
9
  readme = "README.md"
10
10
  keywords = [ "open science", "provenance", "reproducibility", "results" ]
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3.13",
19
19
  "Programming Language :: Python :: 3.14",
20
20
  ]
21
- dependencies = [ "provenance-core>=0.4,<0.5" ]
21
+ dependencies = [ "provenance-core[anchor]>=0.5,<0.6" ]
22
22
  urls.Changelog = "https://github.com/elliottower/reproducible-science/blob/main/CHANGELOG.md"
23
23
  urls.Homepage = "https://github.com/elliottower/reproducible-science"
24
24
  urls.Issues = "https://github.com/elliottower/reproducible-science/issues"
@@ -15,9 +15,9 @@ from __future__ import annotations
15
15
 
16
16
  import pathlib
17
17
 
18
- from provenance_core import sha256_of_tree, try_run
18
+ from provenance_core import anchor, sha256_of_tree, try_run
19
19
 
20
- from results import ledger, manuscript
20
+ from results import ledger, manuscript, stamps
21
21
  from results.paths import ledger_path, require_root
22
22
  from results.timeline import first_outcomes_seen, first_run_timestamp, precedes
23
23
 
@@ -132,6 +132,37 @@ def reanchor() -> int:
132
132
  return 0
133
133
 
134
134
 
135
+ def timestamp() -> int:
136
+ """Stamp the current head, complete every pending proof, and check the complete ones."""
137
+ root = require_root()
138
+ lp = ledger_path(root)
139
+ try:
140
+ made = stamps.stamp_head(lp)
141
+ except anchor.AnchorError as e:
142
+ print(f"not timestamped: {e}")
143
+ return 1
144
+ if made is not None:
145
+ print(f"stamped {made.relative_to(root.parent)}")
146
+ try:
147
+ found = stamps.complete(lp)
148
+ except anchor.AnchorError as e:
149
+ print(f"TIMESTAMP {e}")
150
+ return 1
151
+ dated = [(s, block) for s, block in found if block is not None]
152
+ if dated:
153
+ stamp, block = max(dated, key=lambda pair: pair[0].count)
154
+ print(
155
+ f"events 1–{stamp.count} existed by Bitcoin block {block.height}, "
156
+ f"{block.time:%Y-%m-%d %H:%M} UTC"
157
+ )
158
+ waiting = [s for s, block in found if block is None]
159
+ if waiting:
160
+ latest = max(waiting, key=lambda s: s.count)
161
+ print(f"events 1–{latest.count} pending; Bitcoin usually confirms within hours.")
162
+ print("Commit .results/timestamps/ with the ledger.")
163
+ return 0
164
+
165
+
135
166
  def verify(check_files: bool) -> int:
136
167
  root = require_root()
137
168
  lp = ledger_path(root)
@@ -166,6 +197,28 @@ def verify(check_files: bool) -> int:
166
197
  if state := in_history(lp):
167
198
  print(f" the ledger {state}. A record is evidence once it is in history.\n")
168
199
 
200
+ reading = stamps.read(lp)
201
+ if reading.contradictions:
202
+ # The chain verifies against its own anchor and not against a proof held outside it, so
203
+ # the chain and the anchor were rewritten together after that proof was made.
204
+ print("TIMESTAMP CONTRADICTS THE CHAIN — the ledger was rewritten after it was stamped")
205
+ for problem in reading.contradictions:
206
+ print(f" {problem}")
207
+ return 1
208
+ if reading.dated:
209
+ print(
210
+ f" events 1–{reading.dated.count} dated by Bitcoin block "
211
+ f"{reading.dated.status.blocks[0]}. `results timestamp` checks the block."
212
+ )
213
+ if reading.pending and (not reading.dated or reading.pending.count > reading.dated.count):
214
+ print(
215
+ f" events 1–{reading.pending.count} stamped, pending at "
216
+ f"{len(reading.pending.status.pending)} calendars."
217
+ )
218
+ if not reading.dated and not reading.pending:
219
+ print(" no outside timestamp. `results timestamp` makes one.")
220
+ print()
221
+
169
222
  counts = {}
170
223
  for e in events:
171
224
  t = e.get("event", "?")
@@ -8,6 +8,7 @@ results claim <text> bind a manuscript claim to a run's output
8
8
  results coverage <paper> how many of a manuscript's numbers are bound to a run
9
9
  results verify check the ledger chain and every hash it names
10
10
  results reanchor record the ledger's current length as authoritative
11
+ results timestamp date the ledger's head outside the repository, and check earlier dates
11
12
 
12
13
  This module parses arguments. Each handler unpacks the namespace argparse built and calls into
13
14
  `results.record` or `results.audit`, where the command's logic and its refusals live.
@@ -76,6 +77,10 @@ def cmd_verify(a) -> int:
76
77
  return audit.verify(a.files)
77
78
 
78
79
 
80
+ def cmd_timestamp(a) -> int:
81
+ return audit.timestamp()
82
+
83
+
79
84
  def main(argv: list[str] | None = None) -> int:
80
85
  code = _main(argv)
81
86
  # After the work, never before it, and never instead of it: the note is about how this
@@ -165,6 +170,11 @@ def _main(argv: list[str] | None = None) -> int:
165
170
  ra = sub.add_parser("reanchor", help="record the ledger's current length as authoritative")
166
171
  ra.set_defaults(fn=cmd_reanchor)
167
172
 
173
+ ts = sub.add_parser(
174
+ "timestamp", help="date the ledger's head outside the repository, and check earlier dates"
175
+ )
176
+ ts.set_defaults(fn=cmd_timestamp)
177
+
168
178
  a = ap.parse_args(argv)
169
179
  if not a.cmd:
170
180
  ap.print_help()
@@ -3,9 +3,9 @@
3
3
  **Threat model.** The chain detects accidental damage and casual editing: a crashed write, a
4
4
  bad sync, a file opened in an editor, a line changed by hand. It does not defend against an
5
5
  adversary who can write to the directory, because such an adversary can rewrite the anchor as
6
- readily as the ledger. Defending against that needs an external anchor -- a git note, an
7
- OSF registration, a timestamp authority -- and the head digest here is what you would publish
8
- to one.
6
+ readily as the ledger. Defending against that needs an external anchor, and the head digest here
7
+ is what is published to one: `results timestamp` sends it to the OpenTimestamps calendars, and
8
+ `results.stamps` reads the proofs back against the chain.
9
9
 
10
10
  Saying this plainly matters more than the mechanism. A hash chain is often read as proof of
11
11
  tamper-resistance when it delivers tamper-*evidence*, and only against a party who does not
@@ -25,13 +25,12 @@ import enum
25
25
  import json
26
26
  import os
27
27
  import pathlib
28
- import tempfile
29
28
 
30
29
  # Re-exported under the names this module has always used: `ledger.sha256_of_file` and
31
30
  # `ledger.ZERO` are its surface, and callers should not have to know where they moved.
32
31
  from provenance_core import ZERO as ZERO
32
+ from provenance_core import atomic_write, exclusive_lock, sha256_of_text, shared_lock
33
33
  from provenance_core import sha256_of_file as sha256_of_file
34
- from provenance_core import sha256_of_text
35
34
 
36
35
  LEDGER = "ledger.jsonl"
37
36
  ANCHOR = "ledger.head"
@@ -130,6 +129,11 @@ def last_hash(ledger: pathlib.Path) -> str:
130
129
  return sha256_of_str(lines[-1]) if lines else ZERO
131
130
 
132
131
 
132
+ def line_hashes(ledger: pathlib.Path) -> list[str]:
133
+ """The digest of each line as stored: entry `n` is the head the anchor records at `n + 1`."""
134
+ return [sha256_of_str(line) for line in _lines(ledger)] if ledger.exists() else []
135
+
136
+
133
137
  def read_ledger(ledger: pathlib.Path) -> list[dict]:
134
138
  """Every event, or raise `ChainError` naming the line that is not one."""
135
139
  if not ledger.exists():
@@ -168,29 +172,29 @@ def write_anchor(ledger: pathlib.Path, count: int, head: str) -> dict:
168
172
  it, which would report a complete ledger as truncated.
169
173
  """
170
174
  anchor = {"canon_version": CANON_VERSION, "count": count, "head": head, "updated": now_iso()}
171
- _atomic_write(anchor_path(ledger), json.dumps(anchor, indent=2, sort_keys=True) + "\n")
175
+ atomic_write(anchor_path(ledger), json.dumps(anchor, indent=2, sort_keys=True) + "\n")
172
176
  return anchor
173
177
 
174
178
 
175
- def _atomic_write(path: pathlib.Path, text: str) -> None:
176
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
177
- try:
178
- with os.fdopen(fd, "w", encoding="utf-8") as f:
179
- f.write(text)
180
- f.flush()
181
- os.fsync(f.fileno())
182
- os.replace(tmp, path)
183
- except BaseException:
184
- pathlib.Path(tmp).unlink(missing_ok=True)
185
- raise
186
-
187
-
188
179
  # --------------------------------------------------------------------------------- writing
189
180
 
190
181
 
191
182
  def append_event(ledger: pathlib.Path, event: dict) -> dict:
192
183
  """Write one event and advance the anchor. Returns a new event; the argument is not
193
- mutated, so the object a caller holds cannot drift from the line on disk."""
184
+ mutated, so the object a caller holds cannot drift from the line on disk.
185
+
186
+ An event's position and `prev_hash` come from the lines already on disk, so two callers that
187
+ read the same tail write two lines claiming one position. The chain then reports `edited`,
188
+ and `reanchor` refuses an edited chain, which leaves no way back: the damage is permanent and
189
+ a pipeline running two skills at once is enough to cause it. The lock covers the read as well
190
+ as the write, because reading early is what makes the second line wrong.
191
+ """
192
+ with exclusive_lock(ledger):
193
+ return _append_locked(ledger, event)
194
+
195
+
196
+ def _append_locked(ledger: pathlib.Path, event: dict) -> dict:
197
+ """The append itself. Assumes the caller holds the lock for `ledger`."""
194
198
  lines = _lines(ledger) if ledger.exists() else []
195
199
 
196
200
  # The anchor is the only witness to the last line, and appending overwrites it. An edited
@@ -233,7 +237,18 @@ def verify(ledger: pathlib.Path) -> tuple[ChainStatus, list[str]]:
233
237
 
234
238
  Reports the first structural fault as the status, because a chain that was edited and then
235
239
  truncated is edited: the earlier fault explains the later one.
240
+
241
+ Read under a shared lock. An append writes its line and then its anchor, so a reader arriving
242
+ between the two counted one more event than the anchor records and reported `extended` -- true
243
+ of the bytes, wrong about the cause, and gone on a re-run. A verify that fails only while
244
+ something else is writing teaches people to re-run it until it passes.
236
245
  """
246
+ with shared_lock(ledger):
247
+ return _verify_locked(ledger)
248
+
249
+
250
+ def _verify_locked(ledger: pathlib.Path) -> tuple[ChainStatus, list[str]]:
251
+ """The verification itself. Assumes the caller holds a lock for `ledger`."""
237
252
  if not ledger.exists():
238
253
  return ChainStatus.ABSENT, [f"{ledger} does not exist"]
239
254
 
@@ -314,6 +329,10 @@ def reanchor(ledger: pathlib.Path) -> dict:
314
329
  For a ledger written before anchoring, and for repairing an under-count after a crash.
315
330
  It cannot recover a truncated ledger: re-anchoring a shortened chain records the shortened
316
331
  chain, which is why it is a separate deliberate call and not something verification does.
332
+
333
+ Taken under the lock, so it cannot record a length that an append is in the middle of
334
+ changing and leave the anchor describing neither state.
317
335
  """
318
- lines = _lines(ledger) if ledger.exists() else []
319
- return write_anchor(ledger, len(lines), sha256_of_str(lines[-1]) if lines else ZERO)
336
+ with exclusive_lock(ledger):
337
+ lines = _lines(ledger) if ledger.exists() else []
338
+ return write_anchor(ledger, len(lines), sha256_of_str(lines[-1]) if lines else ZERO)
@@ -25,6 +25,20 @@ ACCESS_LEVELS = [
25
25
  ]
26
26
 
27
27
 
28
+ #: Written into `.results/` so the ledger is tracked without anyone editing the project's own
29
+ #: ignore file. The ledger and its anchor are the record a claim appeals to, and one that exists
30
+ #: on a single disk cannot be appealed to by anyone else; only the lock sidecars are ignored.
31
+ GITIGNORE = """\
32
+ # Commit ledger.jsonl and ledger.head: a record is evidence once it is in history.
33
+ # Everything in the ledger becomes public with the repository, notes included.
34
+
35
+ # Lock sidecars. They hold nothing; the kernel releases a lock when its holder exits.
36
+ *.lock
37
+ *.provenance-lock
38
+ *.provenance-tmp
39
+ """
40
+
41
+
28
42
  def init() -> int:
29
43
  d = pathlib.Path.cwd() / RESULTS_DIR
30
44
  if d.exists():
@@ -34,8 +48,10 @@ def init() -> int:
34
48
  lp = d / ledger.LEDGER
35
49
  lp.touch()
36
50
  ledger.append_event(lp, {"event": "init"})
51
+ (d / ".gitignore").write_text(GITIGNORE)
37
52
  print(f"created {RESULTS_DIR}/")
38
53
  print(f" {ledger.LEDGER} append-only event log")
54
+ print(" .gitignore ignores the lock files only, so the ledger is committed")
39
55
  print("\nseal your inputs before running: `results seal prereg.md script.py data.csv`")
40
56
  return 0
41
57
 
@@ -0,0 +1,125 @@
1
+ """Outside timestamps of the ledger's head, and what they prove about the chain.
2
+
3
+ The anchor file records how long the chain is and where it ends, and whoever can write the ledger
4
+ can write the anchor. A timestamp of the head is held by the OpenTimestamps calendars and then by
5
+ a Bitcoin block, so it says that a ledger of exactly this length and this last line existed by
6
+ the block's date. Every earlier line is covered too, because each line names the hash of the one
7
+ before it.
8
+
9
+ Proofs live under `.results/timestamps/`, one per stamped head, named by the length and the head
10
+ so a reader can see what each covers without opening it: `000153-3f9c2a…ots` covers events 1 to
11
+ 153. They are only ever added. A proof whose head does not match the chain at its length means the
12
+ chain was rewritten after it was stamped, which is the one thing a stamp exists to catch.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pathlib
18
+ import re
19
+ from dataclasses import dataclass
20
+
21
+ from provenance_core import anchor, atomic_write_bytes
22
+
23
+ from results import ledger
24
+
25
+ STAMPS = "timestamps"
26
+ NAME = re.compile(r"^(\d{6})-([0-9a-f]{16})\.ots$")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Stamp:
31
+ path: pathlib.Path
32
+ count: int
33
+ head: str
34
+ status: anchor.Status
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Reading:
39
+ """What the proofs under `.results/timestamps/` say about this chain, read with no network."""
40
+
41
+ dated: Stamp | None
42
+ """The longest stretch of the chain a complete proof covers."""
43
+ pending: Stamp | None
44
+ """The longest stretch a proof covers that Bitcoin has not confirmed yet."""
45
+ contradictions: list[str]
46
+
47
+
48
+ def stamps_dir(lp: pathlib.Path) -> pathlib.Path:
49
+ return lp.parent / STAMPS
50
+
51
+
52
+ def _proofs(lp: pathlib.Path) -> list[tuple[pathlib.Path, int, str]]:
53
+ found = []
54
+ for path in sorted(stamps_dir(lp).glob("*.ots")):
55
+ m = NAME.match(path.name)
56
+ if m:
57
+ found.append((path, int(m.group(1)), m.group(2)))
58
+ return found
59
+
60
+
61
+ def read(lp: pathlib.Path) -> Reading:
62
+ hashes = ledger.line_hashes(lp)
63
+ dated = pending = None
64
+ contradictions = []
65
+ for path, count, short in _proofs(lp):
66
+ if count > len(hashes):
67
+ contradictions.append(
68
+ f"{path.name} dates a chain of {count} events, and the ledger holds {len(hashes)}"
69
+ )
70
+ continue
71
+ head = hashes[count - 1]
72
+ if not head.startswith(short):
73
+ contradictions.append(
74
+ f"{path.name} dates event {count} as {short}…, and the ledger's event {count} "
75
+ f"is {head[:16]}…"
76
+ )
77
+ continue
78
+ try:
79
+ found = Stamp(path, count, head, anchor.status(path.read_bytes(), head))
80
+ except anchor.AnchorError as e:
81
+ contradictions.append(f"{path.name}: {e}")
82
+ continue
83
+ if found.status.is_complete:
84
+ dated = found if dated is None or count > dated.count else dated
85
+ else:
86
+ pending = found if pending is None or count > pending.count else pending
87
+ return Reading(dated, pending, contradictions)
88
+
89
+
90
+ def stamp_head(lp: pathlib.Path) -> pathlib.Path | None:
91
+ """Stamp the head the anchor records, or None where that head is already stamped.
92
+
93
+ Refused on a chain that does not verify: a stamp would date the damage.
94
+ """
95
+ status, problems = ledger.verify(lp)
96
+ if status is not ledger.ChainStatus.INTACT:
97
+ raise ledger.ChainError(
98
+ lp,
99
+ f"refusing to timestamp a chain reported as {status.value}: {'; '.join(problems)}",
100
+ )
101
+ recorded = ledger.read_anchor(lp) or {}
102
+ count, head = recorded["count"], recorded["head"]
103
+ path = stamps_dir(lp) / f"{count:06d}-{head[:16]}.ots"
104
+ if path.exists():
105
+ return None
106
+ path.parent.mkdir(exist_ok=True)
107
+ atomic_write_bytes(path, anchor.stamp(head))
108
+ return path
109
+
110
+
111
+ def complete(lp: pathlib.Path) -> list[tuple[Stamp, anchor.Confirmation | None]]:
112
+ """Fetch the rest of every pending proof, and check every complete one against Bitcoin."""
113
+ hashes = ledger.line_hashes(lp)
114
+ out = []
115
+ for path, count, short in _proofs(lp):
116
+ if count > len(hashes) or not hashes[count - 1].startswith(short):
117
+ continue
118
+ head = hashes[count - 1]
119
+ data = anchor.upgrade(path.read_bytes(), head)
120
+ if data != path.read_bytes():
121
+ atomic_write_bytes(path, data)
122
+ found = anchor.status(data, head)
123
+ block = anchor.confirm(data, head) if found.is_complete else None
124
+ out.append((Stamp(path, count, head, found), block))
125
+ return out
@@ -28,6 +28,28 @@ def test_init_creates_results_dir(tmp_path):
28
28
  assert events[0]["event"] == "init"
29
29
 
30
30
 
31
+ def test_a_new_ledger_is_tracked_by_git_and_its_lock_files_are_not(tmp_path):
32
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True, env=clean_env())
33
+ run_cli("init", cwd=tmp_path)
34
+ (tmp_path / "script.py").write_text("print('hello')\n")
35
+ run_cli("seal", "script.py", cwd=tmp_path)
36
+ (tmp_path / ".results" / "ledger.jsonl.lock").touch()
37
+ (tmp_path / ".results" / "ledger.jsonl.provenance-lock").touch()
38
+
39
+ def ignored(name):
40
+ return (
41
+ subprocess.run(
42
+ ["git", "check-ignore", "-q", f".results/{name}"], cwd=tmp_path, env=clean_env()
43
+ ).returncode
44
+ == 0
45
+ )
46
+
47
+ assert not ignored("ledger.jsonl")
48
+ assert not ignored("ledger.head")
49
+ assert ignored("ledger.jsonl.lock")
50
+ assert ignored("ledger.jsonl.provenance-lock")
51
+
52
+
31
53
  def test_init_twice_fails(tmp_path):
32
54
  run_cli("init", cwd=tmp_path)
33
55
  r = run_cli("init", cwd=tmp_path)
@@ -0,0 +1,148 @@
1
+ """Concurrent appends to one ledger.
2
+
3
+ Two callers that read the same tail compute the same `seq` and the same `prev_hash`, so the chain
4
+ takes two lines claiming one position. Verification reports `edited`, and `reanchor` refuses an
5
+ edited chain, which leaves the ledger permanently unrepairable. An orchestrator running two
6
+ analyses at once is enough to cause it, so these assert the chain survives contention rather than
7
+ that the lock exists.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+
14
+ from results import ledger
15
+
16
+
17
+ def _append_from_threads(lp, threads: int, per_thread: int) -> list[BaseException]:
18
+ failures: list[BaseException] = []
19
+ barrier = threading.Barrier(threads)
20
+
21
+ def worker(n: int) -> None:
22
+ barrier.wait(timeout=30)
23
+ for i in range(per_thread):
24
+ try:
25
+ ledger.append_event(lp, {"event": "run", "thread": n, "i": i})
26
+ except BaseException as e:
27
+ failures.append(e)
28
+
29
+ workers = [threading.Thread(target=worker, args=(n,)) for n in range(threads)]
30
+ for w in workers:
31
+ w.start()
32
+ for w in workers:
33
+ w.join(timeout=60)
34
+ return failures
35
+
36
+
37
+ def test_concurrent_appends_leave_an_intact_chain(tmp_path):
38
+ lp = tmp_path / "ledger.jsonl"
39
+ lp.touch()
40
+ threads, per_thread = 16, 8
41
+
42
+ failures = _append_from_threads(lp, threads, per_thread)
43
+
44
+ assert failures == []
45
+ status, problems = ledger.verify(lp)
46
+ assert (status, problems) == (ledger.ChainStatus.INTACT, [])
47
+
48
+
49
+ def test_concurrent_appends_lose_no_event(tmp_path):
50
+ lp = tmp_path / "ledger.jsonl"
51
+ lp.touch()
52
+ threads, per_thread = 16, 8
53
+
54
+ _append_from_threads(lp, threads, per_thread)
55
+
56
+ events = ledger.read_ledger(lp)
57
+ assert len(events) == threads * per_thread
58
+ assert [e["seq"] for e in events] == list(range(threads * per_thread))
59
+
60
+
61
+ def test_every_concurrent_event_is_recorded_once(tmp_path):
62
+ lp = tmp_path / "ledger.jsonl"
63
+ lp.touch()
64
+ threads, per_thread = 16, 8
65
+
66
+ _append_from_threads(lp, threads, per_thread)
67
+
68
+ events = ledger.read_ledger(lp)
69
+ written = sorted((e["thread"], e["i"]) for e in events)
70
+ expected = sorted((n, i) for n in range(threads) for i in range(per_thread))
71
+ assert written == expected
72
+
73
+
74
+ def test_the_anchor_matches_the_ledger_after_contention(tmp_path):
75
+ lp = tmp_path / "ledger.jsonl"
76
+ lp.touch()
77
+
78
+ _append_from_threads(lp, 12, 6)
79
+
80
+ anchor = ledger.read_anchor(lp)
81
+ lines = lp.read_text(encoding="utf-8").splitlines()
82
+ assert anchor["count"] == len(lines)
83
+ assert anchor["head"] == ledger.sha256_of_str(lines[-1])
84
+
85
+
86
+ def test_a_reanchor_racing_appends_leaves_the_chain_verifiable(tmp_path):
87
+ lp = tmp_path / "ledger.jsonl"
88
+ lp.touch()
89
+ ledger.append_event(lp, {"event": "init"})
90
+ stop = threading.Event()
91
+
92
+ def reanchor_repeatedly() -> None:
93
+ while not stop.is_set():
94
+ ledger.reanchor(lp)
95
+
96
+ spinner = threading.Thread(target=reanchor_repeatedly, daemon=True)
97
+ spinner.start()
98
+ try:
99
+ _append_from_threads(lp, 8, 6)
100
+ finally:
101
+ stop.set()
102
+ spinner.join(timeout=10)
103
+
104
+ ledger.reanchor(lp)
105
+ status, problems = ledger.verify(lp)
106
+ assert (status, problems) == (ledger.ChainStatus.INTACT, [])
107
+
108
+
109
+ APPEND_IN_CHILD = """
110
+ import sys
111
+ from pathlib import Path
112
+ from results import ledger
113
+
114
+ lp, n = Path(sys.argv[1]), int(sys.argv[2])
115
+ for i in range(n):
116
+ ledger.append_event(lp, {"event": "run", "proc": sys.argv[3], "i": i})
117
+ """
118
+
119
+
120
+ def test_separate_processes_appending_leave_an_intact_chain(tmp_path):
121
+ # The case this guards against is two processes, not two threads: an orchestrator running two
122
+ # skills, or a rerun started before the last one finished. `flock` is held per open file
123
+ # description, so a threaded test exercises the same exclusion -- but only a process test
124
+ # shows it holding across the interpreter boundary the real failure crosses.
125
+ import subprocess
126
+ import sys
127
+
128
+ lp = tmp_path / "ledger.jsonl"
129
+ lp.touch()
130
+ script = tmp_path / "append.py"
131
+ script.write_text(APPEND_IN_CHILD)
132
+ per_process, processes = 6, 6
133
+
134
+ running = [
135
+ subprocess.Popen(
136
+ [sys.executable, str(script), str(lp), str(per_process), f"p{n}"],
137
+ stdout=subprocess.PIPE,
138
+ stderr=subprocess.PIPE,
139
+ )
140
+ for n in range(processes)
141
+ ]
142
+ for proc in running:
143
+ _, err = proc.communicate(timeout=120)
144
+ assert proc.returncode == 0, err.decode()[-2000:]
145
+
146
+ status, problems = ledger.verify(lp)
147
+ assert (status, problems) == (ledger.ChainStatus.INTACT, [])
148
+ assert len(ledger.read_ledger(lp)) == per_process * processes
@@ -0,0 +1,142 @@
1
+ """A stamped head catches what the anchor cannot: the ledger and its anchor rewritten together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+ from opentimestamps.core.notary import BitcoinBlockHeaderAttestation, PendingAttestation
9
+ from opentimestamps.core.op import OpSHA256
10
+ from opentimestamps.core.timestamp import Timestamp
11
+ from provenance_core import anchor
12
+ from results import cli, ledger
13
+
14
+ ALICE = "https://alice.btc.calendar.opentimestamps.org"
15
+ BOB = "https://bob.btc.calendar.opentimestamps.org"
16
+
17
+
18
+ class Calendars:
19
+ """Both calendars. Each accepts a commitment; `mine(height)` makes them complete it."""
20
+
21
+ def __init__(self):
22
+ self.height: int | None = None
23
+ self.roots: dict[int, str] = {}
24
+
25
+ def mine(self, height: int) -> None:
26
+ self.height = height
27
+
28
+ def __call__(self, url: str):
29
+ calendars = self
30
+
31
+ class One:
32
+ def __init__(self):
33
+ self.url = url
34
+
35
+ def submit(self, digest, timeout=None):
36
+ timestamp = Timestamp(digest)
37
+ timestamp.attestations.add(PendingAttestation(url))
38
+ return timestamp
39
+
40
+ def get_timestamp(self, commitment, timeout=None):
41
+ if calendars.height is None:
42
+ raise anchor.CommitmentNotFoundError("not yet")
43
+ timestamp = Timestamp(commitment)
44
+ root = timestamp.ops.add(OpSHA256())
45
+ root.attestations.add(BitcoinBlockHeaderAttestation(calendars.height))
46
+ calendars.roots[calendars.height] = root.msg[::-1].hex()
47
+ return timestamp
48
+
49
+ return One()
50
+
51
+ def explorer(self, url: str) -> bytes:
52
+ if "/block-height/" in url:
53
+ return f"hash{url.rsplit('/', 1)[1]}".encode()
54
+ height = int(url.rsplit("hash", 1)[1])
55
+ return json.dumps({"merkle_root": self.roots[height], "timestamp": 1_759_500_000}).encode()
56
+
57
+
58
+ @pytest.fixture
59
+ def calendars(monkeypatch) -> Calendars:
60
+ fake = Calendars()
61
+ monkeypatch.setenv(anchor.CALENDARS_ENV, f"{ALICE},{BOB}")
62
+ monkeypatch.setattr(anchor, "remote", fake)
63
+ monkeypatch.setattr(anchor, "_get", fake.explorer)
64
+ return fake
65
+
66
+
67
+ @pytest.fixture
68
+ def project(tmp_path, monkeypatch):
69
+ monkeypatch.chdir(tmp_path)
70
+ (tmp_path / "data.csv").write_text("a,b\n1,2\n")
71
+ assert cli.main(["init"]) == 0
72
+ assert cli.main(["seal", "data.csv"]) == 0
73
+ return tmp_path / ".results" / "ledger.jsonl"
74
+
75
+
76
+ def test_a_stamp_is_pending_until_mined_and_then_dates_the_whole_chain(project, calendars, capsys):
77
+ assert cli.main(["timestamp"]) == 0
78
+ capsys.readouterr()
79
+ cli.main(["verify"])
80
+ assert "events 1–2 stamped, pending at 2 calendars" in capsys.readouterr().out
81
+
82
+ calendars.mine(915_000)
83
+ assert cli.main(["timestamp"]) == 0
84
+ assert "events 1–2 existed by Bitcoin block 915000" in capsys.readouterr().out
85
+ assert cli.main(["verify"]) == 0
86
+ assert "events 1–2 dated by Bitcoin block 915000" in capsys.readouterr().out
87
+
88
+
89
+ def test_stamping_the_same_head_twice_makes_one_proof(project, calendars):
90
+ cli.main(["timestamp"])
91
+ cli.main(["timestamp"])
92
+
93
+ assert len(list((project.parent / "timestamps").glob("*.ots"))) == 1
94
+
95
+
96
+ def test_a_ledger_rewritten_with_its_anchor_after_a_stamp_is_reported(project, calendars, capsys):
97
+ cli.main(["timestamp"])
98
+ # The owner's rewrite: same length, a different second event, chain and anchor both
99
+ # consistent. `results verify` alone passes this.
100
+ project.unlink()
101
+ ledger.anchor_path(project).unlink()
102
+ (project.parent.parent / "data.csv").write_text("a,b\n9,9\n")
103
+ ledger.append_event(project, {"event": "init"})
104
+ ledger.append_event(project, {"event": "seal", "files": []})
105
+ capsys.readouterr()
106
+
107
+ assert cli.main(["verify"]) == 1
108
+ out = capsys.readouterr().out
109
+ assert "TIMESTAMP CONTRADICTS THE CHAIN" in out
110
+ assert "000002-" in out
111
+
112
+
113
+ def test_a_ledger_truncated_and_reanchored_after_a_stamp_is_reported(project, calendars, capsys):
114
+ (project.parent.parent / "more.csv").write_text("x\n")
115
+ cli.main(["seal", "more.csv"])
116
+ cli.main(["timestamp"])
117
+ lines = project.read_text().splitlines(keepends=True)
118
+ project.write_text("".join(lines[:-1]))
119
+ ledger.anchor_path(project).unlink()
120
+ assert cli.main(["reanchor"]) == 0
121
+ capsys.readouterr()
122
+
123
+ assert cli.main(["verify"]) == 1
124
+ assert "dates a chain of 3 events, and the ledger holds 2" in capsys.readouterr().out
125
+
126
+
127
+ def test_a_damaged_chain_is_not_stamped(project, calendars, capsys):
128
+ project.write_text(project.read_text().replace('"seal"', '"sealed"'))
129
+
130
+ assert cli.main(["timestamp"]) == 2
131
+ assert not (project.parent / "timestamps").exists()
132
+
133
+
134
+ def test_with_no_calendar_reachable_nothing_is_written_and_verify_says_there_is_no_date(
135
+ project, monkeypatch, capsys
136
+ ):
137
+ monkeypatch.setenv(anchor.CALENDARS_ENV, "")
138
+
139
+ assert cli.main(["timestamp"]) == 1
140
+ capsys.readouterr()
141
+ cli.main(["verify"])
142
+ assert "no outside timestamp" in capsys.readouterr().out
File without changes
File without changes