Skip to content
Merged
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ on:

jobs:
test:
runs-on: ubuntu-latest
# Windows is in the matrix because this package READS AND WRITES LEDGER FILES.
# The seal-lookup reads bytes and splits lines itself, so universal-newline
# translation no longer happens for it — a CR-only ledger parsed as one line
# and the lookup answered GENESIS, which on append would have written a second
# genesis entry into the middle of a live chain. Nothing in a Linux-only matrix
# could have shown that. A tamper-evidence tool cannot have an unmeasured OS.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
Expand Down
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,69 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [0.4.0] — 2026-08-25

### Changed
- **Appending no longer re-reads the whole ledger.** `_get_last_seal` runs on every
`record()` and parsed every line to find the last one, so append was O(n) — the
ledger got slower purely by being used, which taxes the discipline it exists to
support. On a 3,097-entry / 3.4 MB ledger one lookup cost **50.390 ms**; it now
costs **0.048 ms** and stays flat (append median over a growing ledger: 8.17× → 0.96×).

Not cached in memory on purpose: other processes append to the same ledger, and a
cached head that is no longer last would write a `prev_seal` that forks the chain.
The file stays the single source of truth; only how much of it is read changed.

### Fixed
- **CR-only line endings answered GENESIS.** Reading bytes meant losing text mode's
universal-newline translation, so a ledger with `\r` endings parsed as one line and
the lookup reported an empty chain — an append would then have written a second
genesis entry into the middle of a live chain. `\r\n`, `\r` and `\n` now all read
the same. Caught by a reviewer who noted the diff was file I/O in a repo with no
Windows CI, not by the tests as first written.

- **The CLI died on consoles that cannot print emoji.** Every verdict line starts with
🪪 / ✅ / 🔴 / ⚪, and on a cp1252 console `print` raised `UnicodeEncodeError`. So on
Windows `am verify` on an **intact** ledger exited 1 with an empty stdout —
indistinguishable from a tamper verdict, and the 0.3.0 promise that "verdicts reach
the exit code" was false there. Worse for `record`: the entry is written *before* the
confirmation is printed, so the action was sealed and the CLI still reported failure —
a caller retrying on non-zero would record it twice.

stdout/stderr now get `errors="replace"` — **the error handler only, not the encoding**.
Forcing UTF-8 fixed the crash and moved it one process along: the child wrote UTF-8 into
a pipe its Windows caller was decoding as cp1252, and the reader died on byte `0x81`
(the 👁 in the witness line) with `stdout` coming back `None`. Keeping the console's own
encoding means whoever reads the output can still decode it; unrepresentable glyphs
degrade to `?` and the verdict words, which are ASCII, come through intact.
A verdict that cannot be printed is a verdict that did not reach anyone.

### Added
- **`windows-latest` in the CI matrix** (3 jobs → 6). This package reads and writes
ledger files; a Linux-only matrix could not show either defect above. It found the
emoji crash on its first run — in the CLI *and*, one round later, in the shipped
example, which prints the same glyphs without going through `_cli()` and so never
inherited the fallback. A tamper-evidence tool cannot have an unmeasured OS.
- A pytest check that runs `examples/demo_family.py` on a cp1252 console. The CI dogfood
step already covered it, but only on the Windows runner and outside the number the
test suite reports: `57/57` was a pytest denominator while CI green's denominator is
pytest + dogfood + package.
- Seal-lookup equivalence tests over 15 awkward ledgers (empty, no trailing newline,
unsealed tail, corrupt lines, a line longer than the read chunk, non-ASCII, CRLF /
CR / mixed endings, missing file), each compared against a full-parse oracle — plus a
**positive control** that runs a knowingly wrong reader through the same comparison,
so "equivalent" cannot quietly mean "measuring nothing".
- A tail-read check measured in **bytes handed out by the file handle**, not wall-clock.
Its first version counted only `read()` and so passed the old line-iterating
implementation unchanged — it measures the difference only after counting iteration too.
- Console-encoding tests driven by `PYTHONIOENCODING` rather than by the OS, so they run
on every runner instead of only the Windows one — including one that decodes the pipe
with the same non-UTF-8 codec the console declared, which is what the Windows harness
does and what the UTF-8-forcing attempt broke. A tamper verdict is checked in that
console too, so the fix cannot quietly turn every run green.

---

## [0.3.0] — 2026-08-14

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion actmirror/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
"witness_peer", "verify_peer", "cross_witness", "family_round", "family_verify",
"report", "Finding",
]
__version__ = "0.3.0"
__version__ = "0.4.0"
94 changes: 88 additions & 6 deletions actmirror/am.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
Zero dependencies (stdlib only). Deterministic. Same DNA as measure-mirror.
"""
from __future__ import annotations
import hashlib, json, os, time
import hashlib, json, os, sys, time
from dataclasses import dataclass


Expand Down Expand Up @@ -70,14 +70,69 @@ def _load_entries(ledger_path: str) -> list[dict]:
return out


def _get_last_seal(ledger_path: str) -> str:
entries = _load_entries(ledger_path)
for e in reversed(entries):
if "seal" in e:
return e["seal"]
def _get_last_seal(ledger_path: str, _chunk: int = 8192) -> str:
"""The seal of the last sealed entry — read from the END of the file.

This runs on EVERY append. Parsing the whole ledger to find its last line made
append O(n): on the family ledger (3,097 entries / 3.4 MB) one `record` spent
50 ms here, and the cost grows with every entry ever written — the ledger gets
slower precisely because it is being used.

Deliberately NOT cached in memory: this ledger is appended by other processes
(cron jobs, sibling agents), and a cached head would hand out a prev_seal that
is no longer last, forking the chain. The file stays the single source of truth;
only the amount of it we read changes.

Semantics are unchanged, including the awkward cases: unsealed or unparseable
trailing lines are skipped (as _load_entries' {_corrupt} placeholders were), a
ledger with no sealed entry at all still answers GENESIS, and CRLF / CR / LF
endings all read the same — text mode used to normalise those for us.
"""
if not os.path.exists(ledger_path):
return "GENESIS"
with open(ledger_path, "rb") as f:
f.seek(0, os.SEEK_END)
pos = f.tell()
buf = b""
while pos > 0:
step = min(_chunk, pos)
pos -= step
f.seek(pos)
buf = f.read(step) + buf
# Split on every line ending, not just \n. Reading bytes means universal-newline
# translation no longer happens for us: a ledger written with CR-only endings
# parsed as ONE line and the lookup answered GENESIS — which would have appended
# a second genesis entry into the middle of a live chain. Normalising first is
# safe because a raw CR or LF inside a JSON string is not valid JSON anyway.
parts = buf.replace(b"\r\n", b"\n").replace(b"\r", b"\n").split(b"\n")
# parts[0] may be the tail of a line that starts earlier in the file —
# only safe to read once we have reached the beginning.
head, complete = parts[0], parts[1:]
for line in reversed(complete):
seal = _seal_of(line)
if seal is not None:
return seal
if pos == 0:
seal = _seal_of(head)
if seal is not None:
return seal
break
buf = head
return "GENESIS"


def _seal_of(raw: bytes):
"""`seal` of one raw ledger line, or None if it has none / does not parse."""
line = raw.strip()
if not line:
return None
try:
entry = json.loads(line.decode("utf-8"))
except Exception:
return None
return entry["seal"] if isinstance(entry, dict) and "seal" in entry else None


def _seal(ledger_path: str, entry: dict, sign_key: str | None = None) -> dict:
entry["prev_seal"] = _get_last_seal(ledger_path)
# `seal` and `sig` are attestation fields, excluded from the content hash.
Expand Down Expand Up @@ -392,6 +447,32 @@ def report(title: str, findings: list[Finding]) -> str:
# ─────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────
def _printable_streams() -> None:
"""Make sure a verdict can always be printed, whatever the console encoding is.

Every verdict line starts with an emoji (🪪 ✅ 🔴 ⚪). On a console whose encoding
cannot represent them — Windows defaults to cp1252 — `print` raised
UnicodeEncodeError, so `am verify` on an INTACT ledger died with a traceback,
an empty stdout and exit 1. Indistinguishable from a tamper verdict, and the
0.3.0 promise that "verdicts reach the exit code" was false on that platform.

Only the error handler changes — NOT the encoding. Forcing UTF-8 here fixed the
crash and moved it one process along: the child then wrote UTF-8 bytes into a pipe
that its Windows caller was decoding with the locale encoding, and the reader died
with UnicodeDecodeError instead. Whoever reads this output already knows the
console's encoding; what they cannot survive is a codec exception. So keep the
encoding they expect and let unrepresentable glyphs degrade to `?` — the verdict
words are ASCII and come through intact either way.

A verdict that cannot be printed is a verdict that did not reach anyone.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(errors="replace")
except Exception: # not a reconfigurable stream (pytest capture, pipes) — fine
pass


def _cli() -> int:
"""Exit codes: 0 — command ran and any verdict was OK/WARN (or the command
has no verdict); 1 — a verdict-bearing command answered negatively
Expand All @@ -400,6 +481,7 @@ def _cli() -> int:
ledger — the verdict was print-only. Found when a commit-binding tool's
tamper demo passed its ledger-mutation case.
"""
_printable_streams()
import argparse
p = argparse.ArgumentParser(
prog="am", description="🪪 Action Mirror — agent action provenance + mutual witness")
Expand Down
7 changes: 7 additions & 0 deletions examples/demo_family.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@

from actmirror import am

# This script prints the same emoji the CLI does, but it does not go through `_cli()`,
# so it did not inherit the console fallback that lives there — and on a cp1252 console
# it died with UnicodeEncodeError. An example is the FIRST code a new user runs; failing
# here is worse than failing in a test. Private import on purpose: this is the package's
# own example, and the helper is not part of the public API.
am._printable_streams()

D = tempfile.mkdtemp(prefix="am_demo_")
ledgers = {n: os.path.join(D, f"{n}.jsonl") for n in ["seara", "jebi", "sonnet"]}

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "action-mirror"
version = "0.3.0"
version = "0.4.0"
description = "Agent action provenance + mutual witness network — who did what, provably."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
Loading
Loading