Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e4d308a
feat(chain): canonical-JSON event hashing (pure stdlib leaf module)
SollanSystems Jul 25, 2026
f054e25
feat(chain): incremental link verification + pure verify_chain over e…
SollanSystems Jul 25, 2026
4d96499
feat(events): store generations — chain columns + user_version, share…
SollanSystems Jul 25, 2026
3e1404f
feat(events): store-computed hash chain on append; typed operational …
SollanSystems Jul 25, 2026
1bcaee3
feat(schema): optional event@1 chain fields with structural-fallback …
SollanSystems Jul 25, 2026
ff15e6b
feat(migrate): explicit legacy-store chain migration verb
SollanSystems Jul 25, 2026
d25aa58
feat(reducer): enforce hash chain at fold time via typed ChainBreakError
SollanSystems Jul 25, 2026
5dad893
feat(runtime): chain surfaced through status/replay/doctor/run; enfor…
SollanSystems Jul 25, 2026
4fb7943
fix(runner): D4 retry parity on the run/simulate read path + typed de…
SollanSystems Jul 25, 2026
23a67ee
feat(doctor): --expect-chain-head anchor gate, downgrade cross-check,…
SollanSystems Jul 25, 2026
d0d5578
test(chain): adversarial coverage + four pinned honest limitations (r…
SollanSystems Jul 25, 2026
6378361
test(chain): pin event-store cleanliness flags on the full-rewrite ho…
SollanSystems Jul 25, 2026
d25a9d1
test(zero-writes): read verbs proven side-effect-free on both store g…
SollanSystems Jul 25, 2026
3a0d2b5
feat(action): record the chain head on every run and optionally enfor…
SollanSystems Jul 25, 2026
2cdf6d8
docs(contract): normative chain canonicalization, conformance vectors…
SollanSystems Jul 25, 2026
d7ccdce
fix(review): whole-branch fix wave — claims-accuracy tightening, test…
SollanSystems Jul 25, 2026
5aa55a2
merge: origin/main (#80 sidecar fix) into feat/event-chain
SollanSystems Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ ships, on disk and runnable today:
- an **event-sourced runtime** — `run`, `status`, `replay`, `simulate`, and
approve/pause/resume/cancel over an append-only SQLite event log
(`.loop/events.db`), folded by a deterministic reducer that enforces the same
completion gate as the writers, with crash-safe single-step resume.
completion gate as the writers, with crash-safe single-step resume. Events are
hash-chained; `loop doctor --expect-chain-head` verifies the log against an
externally anchored head. The chain is tamper-evident **relative to an
anchor** — an adversary with workspace write access can rewrite an unanchored
log.

![The inspector scores a self-asserted DIY loop 0/weak, then the gate-backed example 90/strong — both runs live](docs/demo.gif)

Expand Down
43 changes: 42 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ inputs:
description: "Token for the optional PR scorecard comment. Empty skips the comment."
required: false
default: ""
expect-chain-head:
description: >-
Fail the gate unless the store's chain head equals this 64-hex value (an
externally remembered anchor). Empty performs NO cross-run tamper
detection — the gate then only records the head for a later comparison.
required: false
default: ""

outputs:
chain-head:
description: "Chain head event_hash observed by this gate run ('' when the store has no chained events)."
value: ${{ steps.chain-head.outputs.chain-head }}

runs:
using: "composite"
Expand All @@ -50,12 +62,17 @@ runs:
shell: bash
env:
LOOP_PATH: "${{ inputs.path }}"
LOOP_EXPECT_HEAD: "${{ inputs.expect-chain-head }}"
run: |
# -eo pipefail (GitHub's bash default) makes a doctor failure fail the step
# despite the tee. Then assert the strict validation path actually ran, so a
# future packaging regression that drops the extras fails loudly here. The
# check reads one fixed field from doctor's own JSON — no fragile parsing.
loop doctor "$LOOP_PATH" | tee "${RUNNER_TEMP}/doctor.json"
if [ -n "$LOOP_EXPECT_HEAD" ]; then
loop doctor --expect-chain-head "$LOOP_EXPECT_HEAD" "$LOOP_PATH" | tee "${RUNNER_TEMP}/doctor.json"
else
loop doctor "$LOOP_PATH" | tee "${RUNNER_TEMP}/doctor.json"
fi
python - "${RUNNER_TEMP}/doctor.json" <<'PY'
import json, sys
mode = json.load(open(sys.argv[1])).get("validation_mode")
Expand All @@ -65,6 +82,30 @@ runs:
raise SystemExit(1)
PY

- name: chain head (anchor surface)
id: chain-head
if: always()
shell: bash
run: |
# Runs even when doctor failed: an anchor MISMATCH is exactly the run whose
# observed head an operator needs recorded. Absent/empty doctor.json (doctor
# never got to write one) is a silent no-op, not an error.
[ -s "${RUNNER_TEMP}/doctor.json" ] || exit 0
python - "${RUNNER_TEMP}/doctor.json" "$GITHUB_STEP_SUMMARY" "$GITHUB_OUTPUT" <<'PY'
import json, sys
try:
doctor = json.load(open(sys.argv[1]))
except (OSError, json.JSONDecodeError):
doctor = {}
chain = (doctor.get("event_store") or {}).get("chain") or {}
head = chain.get("head") or {}
value = head.get("event_hash") or ""
line = (f"**loop-engineer chain head:** `{value}` (sequence {head.get('sequence')})"
if value else "**loop-engineer chain head:** none (no chained events)")
open(sys.argv[2], "a").write(line + "\n")
open(sys.argv[3], "a").write(f"chain-head={value}\n")
PY

- name: loop inspect (scorecard)
shell: bash
env:
Expand Down
45 changes: 40 additions & 5 deletions loop/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,20 @@

_PROG = "python3 -m loop"

_COMMANDS = ("scaffold", "doctor", "validate", "verify", "inspect", "metrics", "plan-lint", "status", "replay", "simulate", "run", "approve", "pause", "resume", "cancel", "architect")
_COMMANDS = ("scaffold", "doctor", "validate", "verify", "inspect", "metrics", "plan-lint", "status", "replay", "simulate", "run", "approve", "pause", "resume", "cancel", "migrate", "architect")

# Read commands operate on an EXISTING contract dir; scaffold CREATES one, so it
# is exempt from the "target must exist" guard.
_READ_COMMANDS = ("doctor", "validate", "verify", "inspect", "metrics", "plan-lint", "status", "replay", "simulate", "run", "approve", "pause", "resume", "cancel")
_READ_COMMANDS = ("doctor", "validate", "verify", "inspect", "metrics", "plan-lint", "status", "replay", "simulate", "run", "approve", "pause", "resume", "cancel", "migrate")

_USAGE = f"usage: {_PROG} <scaffold|doctor|validate|verify|inspect|metrics|plan-lint|status|replay|simulate|run|approve|pause|resume|cancel|architect> <target>"
_USAGE = f"usage: {_PROG} <scaffold|doctor|validate|verify|inspect|metrics|plan-lint|status|replay|simulate|run|approve|pause|resume|cancel|migrate|architect> <target>"

_HELP = f"""{_PROG} — validate, inspect, and measure a portable repo-OS loop contract.

{_USAGE}
{_PROG} metrics [--baseline] <workspace-or-.loop>
{_PROG} doctor|validate|verify [--mode basic|strict|release] <workspace-or-.loop>
{_PROG} doctor|validate|verify [--mode basic|strict|release]
[--expect-chain-head SHA256] <workspace-or-.loop>
{_PROG} status [--mode basic|strict|release] <workspace>
{_PROG} replay [--mode basic|strict|release] <workspace>
{_PROG} simulate [--mode basic|strict|release] <workspace>
Expand All @@ -33,6 +34,7 @@
{_PROG} pause --reason REASON [--mode basic|strict|release] <workspace>
{_PROG} resume [--note NOTE] [--mode basic|strict|release] <workspace>
{_PROG} cancel [--reason REASON] [--mode basic|strict|release] <workspace>
{_PROG} migrate <workspace>
{_PROG} plan-lint [--mode basic|strict|release] <plan-file>

commands:
Expand All @@ -57,6 +59,7 @@
pause Pause a non-terminal run.
resume Resume a paused run.
cancel Terminate a non-terminal run as AbortedByHuman.
migrate Add hash-chain columns to a legacy events.db (explicit, idempotent; the only store-upgrade path).
architect Not implemented by this CLI: architecture classification and ADR
authorship require agentic judgment, not deterministic code. See
the loop-architect skill.
Expand All @@ -69,6 +72,10 @@
--mode {{basic,strict,release}}
(doctor/validate/verify/plan-lint/status/replay/simulate/run) basic forces structural
checks; strict/release require jsonschema. Default: auto-detect.
--expect-chain-head SHA256
(doctor/validate/verify) fail unless the event store's chain head
is exactly this 64-character lowercase hex hash. A missing,
unreadable, unchained, or diverged store fails the gate.
--baseline (metrics only) write docs/metrics-baseline.json over a gate-backed
run; exits non-zero and writes nothing otherwise.
-h, --help Show this help and exit.
Expand Down Expand Up @@ -238,6 +245,26 @@ def main(argv: list[str] | None = None) -> int:
print(_USAGE, file=sys.stderr)
return 2

expect_chain_head = None
if command in {"doctor", "validate", "verify"}:
try:
expect_chain_head, argv = _extract_value_flag(argv, "--expect-chain-head")
except ValueError as exc:
print(f"{command}: {exc}", file=sys.stderr)
print(_USAGE, file=sys.stderr)
return 2
if expect_chain_head is not None and re.fullmatch(r"[0-9a-f]{64}", expect_chain_head) is None:
print(f"{command}: --expect-chain-head must be a 64-character lowercase hex sha256",
file=sys.stderr)
return 2
elif any(a == "--expect-chain-head" or a.startswith("--expect-chain-head=") for a in argv):
# No generic unknown-flag guard exists for the other commands, and scaffold
# would otherwise CREATE a directory named after the flag.
print(f"{command}: --expect-chain-head is only valid for doctor/validate/verify",
file=sys.stderr)
print(_USAGE, file=sys.stderr)
return 2

decision = resume_target = reason = note = None
if command in {"approve", "pause", "resume", "cancel"}:
try:
Expand Down Expand Up @@ -308,7 +335,7 @@ def main(argv: list[str] | None = None) -> int:

if command in {"doctor", "validate", "verify"}:
try:
return _print_json(doctor_report(target, mode=mode))
return _print_json(doctor_report(target, mode=mode, expect_chain_head=expect_chain_head))
except ValidationModeError as exc:
print(f"{command}: {exc}", file=sys.stderr)
return 2
Expand All @@ -320,6 +347,14 @@ def main(argv: list[str] | None = None) -> int:
print(f"{command}: {exc}", file=sys.stderr)
return 2

if command == "migrate":
from .migrate import migrate_store
try:
return _print_json(migrate_store(target))
except RuntimeStoreError as exc:
print(f"migrate: {exc}", file=sys.stderr)
return 2

if command in {"status", "replay", "simulate"}:
from .runner import RunnerError
try:
Expand Down
88 changes: 88 additions & 0 deletions loop/chain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Pure hash-chain canonicalization and verification for event@1 records.

Stdlib-only and import-free of other loop modules: verify_chain() must work over
any ordered event list (a SQLite read or a JSONL export) so third parties can
re-verify a chain without this package's store code. Canonical form is
json.dumps(sort_keys, separators=(",",":"), ensure_ascii=False, allow_nan=False)
encoded UTF-8 — pinned normatively in reference/repo-os-contract.md #16.
"""

from __future__ import annotations

import hashlib
import json
from typing import Any, Iterable, Mapping

_PREIMAGE_FIELDS = (
"schema", "run_id", "sequence", "event_id", "type", "actor", "ts",
"causation_id", "correlation_id", "payload", "artifact_hashes",
"prev_event_hash",
)


class ChainHashError(ValueError):
"""A value cannot be canonically hashed (non-JSON type, non-finite float, lone surrogate)."""


def canonical_json(value: Any) -> str:
try:
text = json.dumps(value, sort_keys=True, separators=(",", ":"),
ensure_ascii=False, allow_nan=False)
except (TypeError, ValueError) as exc:
raise ChainHashError(f"value is not canonically serializable: {exc}") from exc
try:
text.encode("utf-8")
except UnicodeEncodeError as exc:
raise ChainHashError(f"value contains a lone surrogate: {exc}") from exc
return text


def compute_event_hash(record: Mapping[str, Any]) -> str:
preimage = {field: record.get(field) for field in _PREIMAGE_FIELDS}
return hashlib.sha256(canonical_json(preimage).encode("utf-8")).hexdigest()


def link_issue(record: Mapping[str, Any], prev_head: Mapping[str, Any] | None) -> str | None:
"""One incremental chain check; None means record legally extends prev_head."""
sequence = record.get("sequence")
stored = record.get("event_hash")
if stored is None:
if prev_head is None:
return None
return (f"unchained event after chained prefix at sequence {sequence!r} "
"(a pre-0.10.0 writer appended to a chained store, or the row was tampered)")
expected_prev = prev_head["event_hash"] if prev_head is not None else None
if record.get("prev_event_hash") != expected_prev:
return f"prev_event_hash mismatch at sequence {sequence!r}"
try:
recomputed = compute_event_hash(record)
except ChainHashError as exc:
return f"unhashable record at sequence {sequence!r}: {exc}"
if recomputed != stored:
return f"event_hash mismatch at sequence {sequence!r}"
return None


def verify_chain(events: Iterable[Mapping[str, Any]], *, expected_head: str | None = None) -> dict[str, Any]:
"""Verify a COMPLETE run stream's hash chain (sequence 0 onward); pure, I/O-free."""
issues: list[str] = []
unchained_prefix = 0
chained_events = 0
head: dict[str, Any] | None = None
for record in events:
issue = link_issue(record, head)
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-contiguous streams before verifying links

verify_chain() never checks that the first event has sequence 0 or that later sequences are contiguous. Consequently, deleting rows from an unchained legacy prefix—or passing a self-consistent genesis whose sequence is greater than zero—still returns ok: true and underreports unchained_prefix, even though this API is documented as verifying a complete run stream. Validate sequence continuity while iterating so incomplete exports cannot be certified.

Useful? React with 👍 / 👎.

if issue is not None:
issues.append(issue)
break
if record.get("event_hash") is None:
unchained_prefix += 1
continue
chained_events += 1
head = {"sequence": record.get("sequence"), "event_hash": record["event_hash"]}
if expected_head is not None and not issues:
if head is None:
issues.append("expected chain head, but the stream has no chained events")
elif head["event_hash"] != expected_head:
issues.append(f"chain head {head['event_hash']} does not match expected {expected_head}")
return {"ok": not issues, "issues": issues, "chained_events": chained_events,
"unchained_prefix": unchained_prefix, "head": head}
6 changes: 4 additions & 2 deletions loop/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,11 +749,13 @@ def validate_contract(target: str | Path, *, mode: str | None = None) -> dict[st
}


def doctor_report(target: str | Path, *, mode: str | None = None) -> dict[str, Any]:
def doctor_report(target: str | Path, *, mode: str | None = None,
expect_chain_head: str | None = None) -> dict[str, Any]:
report = validate_contract(target, mode=mode)
from .runtime import event_consistency_issues

event_store, event_issues = event_consistency_issues(target, mode=mode)
event_store, event_issues = event_consistency_issues(
target, mode=mode, expect_chain_head=expect_chain_head)
issues = report["issues"] + list(event_issues) if event_issues else report["issues"]
return {**report, "event_store": event_store, "issues": issues,
"ok": report["ok"] and not event_issues}
Loading