From 39bcad21155a52287d65bd232b3631a2fbf17e46 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:36:24 +0300 Subject: [PATCH 01/12] feat(ledger): read and validate correction lines Signed-off-by: NovusEdge --- docket/corrections.py | 77 +++++++++++++++++++++ docket/ledger.py | 23 ++++-- tests/test_corrections.py | 142 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 docket/corrections.py create mode 100644 tests/test_corrections.py diff --git a/docket/corrections.py b/docket/corrections.py new file mode 100644 index 0000000..fa0fd37 --- /dev/null +++ b/docket/corrections.py @@ -0,0 +1,77 @@ +"""Field-level corrections to earlier ledger records. + +A correction line names one earlier record and replaces some of its wording +and metadata. The record keeps its id, so every relation, feature include, +and citation that names it still does. Supersession stays the way to change +what a record commits to, which is why choice, state, and relations are fixed. +""" + +from __future__ import annotations + +import copy +import re +from typing import Any + +KIND = "correction" +CORRECTION_RE = re.compile(r"([cdq](?:0|[1-9][0-9]*))\.([1-9][0-9]*)") +_COMMON = frozenset( + {"text", "rationale", "scope", "cost_if_wrong", "evidence", "revisit", "pinned"} +) +_DECISION_ONLY = frozenset({"alternatives", "decided_by"}) +_LINE_FIELDS = frozenset( + {"schema", "kind", "id", "corrects", "fields", "reason", "ts", "author", "session", "branch"} +) + + +def split_id(ident: str) -> tuple[str, int] | None: + match = CORRECTION_RE.fullmatch(ident) + return (match.group(1), int(match.group(2))) if match else None + + +def correctable(kind: str) -> frozenset[str]: + return _COMMON | _DECISION_ONLY if kind == "decision" else _COMMON + + +def validate(record: dict[str, Any], prefix: Any) -> dict[str, Any]: + """Validate a correction line; ``prefix`` is a ledger._Prefix or None. + + Without a prefix only the line's own shape is checked, as validate_record + does for a record. + """ + from docket.ledger import SCHEMA, _error, validate_record + + ident = record.get("id") + parts = split_id(ident) if isinstance(ident, str) else None + if parts is None: + raise _error("record", "correction id must match . with n from 1") + unknown = sorted(set(record) - _LINE_FIELDS) + if unknown: + raise _error(ident, f"unknown field(s): {', '.join(unknown)}") + if type(record.get("schema")) is not int or record["schema"] != SCHEMA: + raise _error("schema", f"expected schema {SCHEMA}, got {record.get('schema')!r}") + for field in ("ts", "author", "session", "branch", "reason"): + if not isinstance(record.get(field), str): + raise _error(ident, f"{field} must be a string") + target_id, number = parts + if record.get("corrects") != target_id: + raise _error(ident, "a correction id must start with the id it corrects") + fields = record.get("fields") + if not isinstance(fields, dict) or not fields: + raise _error(ident, "fields must be a non-empty object") + if prefix is None: + return copy.deepcopy(record) + target = prefix.by_id.get(target_id) + if target is None: + raise _error(ident, f"corrects unknown or later ID {target_id!r}") + refused = sorted(set(fields) - correctable(target["kind"])) + if refused: + raise _error( + ident, + f"cannot correct {', '.join(refused)} on a {target['kind']}; " + "supersede the record to change it", + ) + if number <= prefix.corrections.get(target_id, 0): + raise _error(ident, "correction numbers must increase for each record; gaps are allowed") + # The record's own type rules check each replacement value. + validate_record({**target, **copy.deepcopy(fields)}) + return copy.deepcopy(record) diff --git a/docket/ledger.py b/docket/ledger.py index 7ad8172..9e03c8d 100644 --- a/docket/ledger.py +++ b/docket/ledger.py @@ -17,6 +17,8 @@ from pathlib import Path from typing import Any, Iterator +from docket import corrections + SCHEMA = 2 KINDS = ("claim", "decision", "question") STATES = { @@ -231,23 +233,30 @@ def make_record( class _Prefix: - """The id map, sequence maximum, and retirement map of the records so far. + """The id map, sequence maximum, retirement map, and correction counters + of the records so far. Validating a whole ledger walks the prefix once per record. Deriving these - three from the prefix list each time made a read cost O(n squared), so a - caller that validates in order updates one of these instead. + from the prefix list each time made a read cost O(n squared), so a caller + that validates in order updates one of these instead. """ - __slots__ = ("by_id", "max_number", "retired") + __slots__ = ("by_id", "max_number", "retired", "corrections") def __init__(self, entries: list[dict[str, Any]]) -> None: self.by_id: dict[str, dict[str, Any]] = {} self.max_number = 0 self.retired: dict[str, str] = {} + self.corrections: dict[str, int] = {} for entry in entries: self.add(entry) def add(self, entry: dict[str, Any]) -> None: + # A correction is no relation target and carries no supersedes. + if entry.get("kind") == corrections.KIND: + target, number = corrections.split_id(entry["id"]) + self.corrections[target] = max(self.corrections.get(target, 0), number) + return ident = entry["id"] self.by_id[ident] = entry self.max_number = max(self.max_number, int(ident[1:])) @@ -268,6 +277,12 @@ def validate_record( raise _error("record", "each JSONL line must be an object") if any(not isinstance(key, str) for key in record): raise _error("record", "field names must be strings") + if record.get("kind") == corrections.KIND: + if prefix is not None and previous is not None: + raise _error("record", "pass previous or prefix, not both") + if prefix is None and previous is not None: + prefix = _Prefix(previous) + return corrections.validate(record, prefix) if record.get("schema") in (None, 1): raise _error( "schema", "legacy format is unsupported; run 'docket migrate' to convert it to schema 2" diff --git a/tests/test_corrections.py b/tests/test_corrections.py new file mode 100644 index 0000000..0218ca6 --- /dev/null +++ b/tests/test_corrections.py @@ -0,0 +1,142 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import docket.ledger as ledger +from docket import corrections + + +def line(ident, target, fields, **extra): + record = { + "schema": 2, + "kind": "correction", + "id": ident, + "corrects": target, + "fields": fields, + "reason": "", + "ts": "2026-09-23T00:00:00+00:00", + "author": "test", + "session": "", + "branch": "", + } + record.update(extra) + return record + + +def decision(ident, text="The cache lives in Redis.", **kwargs): + kwargs.setdefault("choice", "Redis") + return ledger.make_record("decision", text, author="test", record_id=ident, **kwargs) + + +def claim(ident, text="Writes are durable.", **kwargs): + return ledger.make_record("claim", text, author="test", record_id=ident, **kwargs) + + +class ValidationTests(unittest.TestCase): + def test_a_correction_of_an_earlier_record_reads(self): + entries = ledger.validate_entries([decision("d1"), line("d1.1", "d1", {"scope": ["a.py"]})]) + self.assertEqual(entries[1]["id"], "d1.1") + + def test_the_target_must_be_earlier(self): + with self.assertRaisesRegex(ledger.LedgerError, "unknown or later"): + ledger.validate_entries([line("d1.1", "d1", {"scope": []}), decision("d1")]) + + def test_the_id_base_must_equal_corrects(self): + with self.assertRaisesRegex(ledger.LedgerError, "must start with"): + ledger.validate_entries([decision("d1"), decision("d2"), line("d1.1", "d2", {"scope": []})]) + + def test_a_malformed_id_is_refused(self): + with self.assertRaisesRegex(ledger.LedgerError, "."): + ledger.validate_entries([decision("d1"), line("d1.0", "d1", {"scope": []})]) + + def test_fixed_fields_are_refused(self): + for field, value in ( + ("choice", "Postgres"), + ("state", "revoked"), + ("supports", [["c1"]]), + ("supersedes", []), + ("kind", "claim"), + ("author", "someone"), + ): + with self.assertRaisesRegex(ledger.LedgerError, "supersede", msg=field): + ledger.validate_entries([decision("d1"), line("d1.1", "d1", {field: value})]) + + def test_decision_only_fields_are_refused_on_a_claim(self): + with self.assertRaisesRegex(ledger.LedgerError, "alternatives"): + ledger.validate_entries([claim("c1"), line("c1.1", "c1", {"alternatives": ["x"]})]) + + def test_a_value_of_the_wrong_type_is_refused(self): + with self.assertRaisesRegex(ledger.LedgerError, "scope"): + ledger.validate_entries([decision("d1"), line("d1.1", "d1", {"scope": "a.py"})]) + with self.assertRaisesRegex(ledger.LedgerError, "pinned"): + ledger.validate_entries([decision("d1"), line("d1.1", "d1", {"pinned": "yes"})]) + + def test_empty_fields_are_refused(self): + with self.assertRaisesRegex(ledger.LedgerError, "non-empty"): + ledger.validate_entries([decision("d1"), line("d1.1", "d1", {})]) + + def test_unknown_line_fields_are_refused(self): + with self.assertRaisesRegex(ledger.LedgerError, "unknown field"): + ledger.validate_entries( + [decision("d1"), line("d1.1", "d1", {"scope": []}, note="x")] + ) + + def test_n_must_increase_per_target_and_gaps_are_allowed(self): + ledger.validate_entries( + [ + decision("d1"), + decision("d2"), + line("d1.1", "d1", {"scope": []}), + line("d2.1", "d2", {"scope": []}), + line("d1.3", "d1", {"scope": ["b"]}), + ] + ) + with self.assertRaisesRegex(ledger.LedgerError, "must increase"): + ledger.validate_entries( + [decision("d1"), line("d1.2", "d1", {"scope": []}), line("d1.1", "d1", {"scope": []})] + ) + + def test_a_retired_record_is_correctable(self): + ledger.validate_entries( + [ + decision("d1"), + decision("d2", supersedes=["d1"]), + line("d1.1", "d1", {"rationale": "Latency."}), + ] + ) + + def test_nothing_may_point_at_a_correction(self): + with self.assertRaisesRegex(ledger.LedgerError, "unknown or later"): + ledger.validate_entries( + [ + claim("c1"), + line("c1.1", "c1", {"scope": []}), + decision("d2", supports=[["c1.1"]]), + ] + ) + + def test_record_numbering_ignores_corrections(self): + entries = ledger.validate_entries([decision("d1"), line("d1.1", "d1", {"scope": []})]) + self.assertEqual(ledger.allocate_id(entries, "claim"), "c2") + + +class ReadTests(unittest.TestCase): + def test_read_returns_correction_lines_in_file_order(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "ledger.jsonl" + import json + + path.write_text( + "\n".join( + json.dumps(item) + for item in (decision("d1"), line("d1.1", "d1", {"scope": ["a.py"]})) + ) + + "\n" + ) + self.assertEqual([item["id"] for item in ledger.read(path)], ["d1", "d1.1"]) + + +if __name__ == "__main__": + unittest.main() From 9d4fedce08bdcfaa3daa710fa6d7c927b9183bba Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:37:18 +0300 Subject: [PATCH 02/12] feat(ledger): fold corrections into projected records Signed-off-by: NovusEdge --- docket/corrections.py | 26 ++++++++++++++++++++ docket/ledger.py | 1 + tests/test_corrections.py | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/docket/corrections.py b/docket/corrections.py index fa0fd37..e93361a 100644 --- a/docket/corrections.py +++ b/docket/corrections.py @@ -75,3 +75,29 @@ def validate(record: dict[str, Any], prefix: Any) -> dict[str, Any]: # The record's own type rules check each replacement value. validate_record({**target, **copy.deepcopy(fields)}) return copy.deepcopy(record) + + +def fold(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Records with their corrections applied, the correction lines removed. + + ``original`` holds each corrected field's value before its first + correction. A record with no corrections is passed through untouched, so + its revision digest does not change. + """ + result: list[dict[str, Any]] = [] + index: dict[str, int] = {} + for entry in entries: + if entry.get("kind") != KIND: + index[entry["id"]] = len(result) + result.append(entry) + continue + position = index[entry["corrects"]] + current = dict(result[position]) + original = dict(current.get("original", {})) + for field, value in entry["fields"].items(): + original.setdefault(field, copy.deepcopy(current.get(field))) + current[field] = copy.deepcopy(value) + current["original"] = original + current["corrections"] = [*current.get("corrections", []), entry["id"]] + result[position] = current + return result diff --git a/docket/ledger.py b/docket/ledger.py index 9e03c8d..9a07874 100644 --- a/docket/ledger.py +++ b/docket/ledger.py @@ -599,6 +599,7 @@ def project(entries: list[dict[str, Any]], *, validated: bool = False) -> list[d """ if not validated: entries = validate_entries(entries) + entries = corrections.fold(entries) retired = retired_by(entries) answers = resolved_by(entries) applicability, blocked = _decision_applicability(entries, retired) diff --git a/tests/test_corrections.py b/tests/test_corrections.py index 0218ca6..14c64dd 100644 --- a/tests/test_corrections.py +++ b/tests/test_corrections.py @@ -6,6 +6,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) import docket.ledger as ledger from docket import corrections +from docket.context_model import _revision def line(ident, target, fields, **extra): @@ -138,5 +139,55 @@ def test_read_returns_correction_lines_in_file_order(self): self.assertEqual([item["id"] for item in ledger.read(path)], ["d1", "d1.1"]) +class ProjectionTests(unittest.TestCase): + def test_corrections_apply_in_file_order_and_the_lines_disappear(self): + projected = ledger.project( + [ + decision("d1", scope=["lib/a.py"]), + line("d1.1", "d1", {"scope": ["docket/a.py"], "rationale": "First."}), + line("d1.2", "d1", {"rationale": "Second."}), + ] + ) + self.assertEqual([item["id"] for item in projected], ["d1"]) + record = projected[0] + self.assertEqual(record["scope"], ["docket/a.py"]) + self.assertEqual(record["rationale"], "Second.") + self.assertEqual(record["corrections"], ["d1.1", "d1.2"]) + self.assertEqual(record["original"], {"scope": ["lib/a.py"], "rationale": ""}) + + def test_an_empty_list_clears_a_field(self): + projected = ledger.project([decision("d1", scope=["a.py"]), line("d1.1", "d1", {"scope": []})]) + self.assertEqual(projected[0]["scope"], []) + + def test_an_uncorrected_record_gains_no_keys(self): + records = [decision("d1"), claim("c2")] + before = ledger.project(records) + after = ledger.project(records + [line("c2.1", "c2", {"revisit": "Later."})]) + self.assertNotIn("corrections", after[0]) + self.assertNotIn("original", after[0]) + self.assertEqual(_revision(before[:1]), _revision(after[:1])) + + def test_a_correction_of_a_retired_record_keeps_it_retired(self): + projected = ledger.project( + [ + decision("d1"), + decision("d2", supersedes=["d1"]), + line("d1.1", "d1", {"rationale": "Latency."}), + ] + ) + self.assertEqual(projected[0]["retired_by"], "d2") + self.assertEqual(projected[0]["rationale"], "Latency.") + + def test_a_pin_correction_changes_the_pin(self): + projected = ledger.project([claim("c1"), line("c1.1", "c1", {"pinned": True})]) + self.assertTrue(projected[0]["pinned"]) + + def test_graph_payload_carries_the_corrected_text(self): + payload = ledger.graph_payload( + [decision("d1"), line("d1.1", "d1", {"text": "The cache lives in Valkey."})] + ) + self.assertEqual(payload["entries"][0]["question"], "The cache lives in Valkey.") + + if __name__ == "__main__": unittest.main() From e1e4c215bfda7598b772e8a25c2f9c786c58dfca Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:38:33 +0300 Subject: [PATCH 03/12] feat(ledger): allocate corrections under the lock and refuse echoes Signed-off-by: NovusEdge --- docket/corrections.py | 54 ++++++++++++++++++++++++++ docket/ledger.py | 50 +++++++++++++++++++------ tests/test_corrections.py | 79 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) diff --git a/docket/corrections.py b/docket/corrections.py index e93361a..f2d3f8b 100644 --- a/docket/corrections.py +++ b/docket/corrections.py @@ -101,3 +101,57 @@ def fold(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: current["corrections"] = [*current.get("corrections", []), entry["id"]] result[position] = current return result + + +def allocate(entries: list[dict[str, Any]], target: str) -> str: + highest = 0 + for entry in entries: + if entry.get("kind") == KIND and entry.get("corrects") == target: + highest = max(highest, split_id(entry["id"])[1]) + return f"{target}.{highest + 1}" + + +def make( + target: str, + fields: dict[str, Any], + *, + reason: str = "", + author: str = "unknown", + session: str = "", + branch: str = "", + ts: str | None = None, +) -> dict[str, Any]: + """An unnumbered correction line; append allocates the id under its lock.""" + from datetime import datetime, timezone + + from docket.ledger import SCHEMA + + return { + "schema": SCHEMA, + "kind": KIND, + "id": "", + "corrects": target, + "fields": copy.deepcopy(fields), + "reason": reason, + "ts": ts if ts is not None else datetime.now(timezone.utc).isoformat(timespec="seconds"), + "author": author, + "session": session, + "branch": branch, + } + + +def refuse(entries: list[dict[str, Any]], correction: dict[str, Any]) -> None: + """Run the write-time refusals on the record as this correction leaves it. + + Only the fields the correction replaces are checked, against the record + as earlier corrections left it. + """ + from docket.ledger import _reject_empty_reasoning, _reject_question_text + + fields = correction["fields"] + target = next(item for item in fold(entries) if item["id"] == correction["corrects"]) + record = {**target, **fields} + if "text" in fields: + _reject_question_text(record) + if record["kind"] == "decision": + _reject_empty_reasoning(record, frozenset(fields)) diff --git a/docket/ledger.py b/docket/ledger.py index 9a07874..5d8d195 100644 --- a/docket/ledger.py +++ b/docket/ledger.py @@ -76,7 +76,9 @@ def _normalized(value: str) -> str: return " ".join(value.split()).casefold() -def _reject_empty_reasoning(record: dict[str, Any]) -> None: +def _reject_empty_reasoning( + record: dict[str, Any], changed: frozenset[str] | None = None +) -> None: """Refuse a decision whose text or reasoning fields only echo the choice. Requiring the choice to appear in ``alternatives`` made a one-element list @@ -86,25 +88,43 @@ def _reject_empty_reasoning(record: dict[str, Any]) -> None: refused, which leaves no reason to invent an alternative that never existed. This runs when a record is written, never when one is read. A ledger - recorded under the old rule stays readable. + recorded under the old rule stays readable. ``changed`` names the fields a + correction replaces, and each check then runs only when its own field + changed: 27 migrated decisions carry a rationale equal to their choice, + and a scope correction must not fail on it. """ + + def touched(*names: str) -> bool: + return changed is None or any(name in changed for name in names) + choice = _normalized(record.get("choice", "")) alternatives = [_normalized(item) for item in record.get("alternatives", [])] - if choice and alternatives and all(item == choice for item in alternatives): + if ( + touched("alternatives") + and choice + and alternatives + and all(item == choice for item in alternatives) + ): raise _error( "record", "decision alternatives must name an option the choice beat; leave " "--alternative off when the decision had no contender", ) text = _normalized(record.get("text", "")) - if choice and text == choice: + if touched("text") and choice and text == choice: raise _error( "record", "decision text must carry more than the choice; name what the " "decision commits to and leave the option detail in --choice", ) rationale = _normalized(record.get("rationale", "")) - if rationale and rationale in (choice, text): + if touched("rationale"): + against: tuple[str, ...] = (choice, text) + elif touched("text"): + against = (text,) + else: + against = () + if rationale and rationale in against: raise _error( "record", "decision rationale must say why the choice won; leave --rationale off " @@ -692,8 +712,10 @@ def _ledger_lock(path: Path, exclusive: bool = True) -> Iterator[None]: def append(path: Path | str, record: dict[str, Any]) -> dict[str, Any]: """Validate and append one record under a process lock. - The caller may supply an empty id; in that case the global sequence is - allocated while holding the lock, preventing duplicate IDs between writers. + The caller may supply an empty id; the record id, or a correction's + `.`, is then allocated while holding the lock, preventing + duplicate IDs between writers. A correction's write-time refusals run on + the projection read under the same lock. """ path = Path(path) with _ledger_lock(path): @@ -701,10 +723,16 @@ def append(path: Path | str, record: dict[str, Any]) -> dict[str, Any]: # would block against the lock this call already holds. entries = read(path, lock=False) candidate = copy.deepcopy(record) - if not candidate.get("id"): - kind = candidate.get("kind") or "" - candidate["id"] = allocate_id(entries, kind) - validate_record(candidate, previous=entries) + if candidate.get("kind") == corrections.KIND: + if not candidate.get("id"): + candidate["id"] = corrections.allocate(entries, str(candidate.get("corrects", ""))) + validate_record(candidate, previous=entries) + corrections.refuse(entries, candidate) + else: + if not candidate.get("id"): + kind = candidate.get("kind") or "" + candidate["id"] = allocate_id(entries, kind) + validate_record(candidate, previous=entries) path.parent.mkdir(parents=True, exist_ok=True) try: with path.open("a+", encoding="utf-8") as stream: diff --git a/tests/test_corrections.py b/tests/test_corrections.py index 14c64dd..45d3632 100644 --- a/tests/test_corrections.py +++ b/tests/test_corrections.py @@ -1,6 +1,7 @@ import sys import tempfile import unittest +from concurrent.futures import ThreadPoolExecutor from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -189,5 +190,83 @@ def test_graph_payload_carries_the_corrected_text(self): self.assertEqual(payload["entries"][0]["question"], "The cache lives in Valkey.") +class AppendTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "ledger.jsonl" + + def tearDown(self): + self.tmp.cleanup() + + def add(self, kind, text, **kwargs): + return ledger.append(self.path, ledger.make_record(kind, text, author="test", **kwargs)) + + def correct(self, target, fields): + return ledger.append(self.path, corrections.make(target, fields, author="test")) + + def test_append_numbers_corrections_per_record(self): + self.add("decision", "The cache lives in Redis.", choice="Redis") + self.add("claim", "Writes are durable.") + self.assertEqual(self.correct("d1", {"scope": ["a"]})["id"], "d1.1") + self.assertEqual(self.correct("c2", {"scope": ["a"]})["id"], "c2.1") + self.assertEqual(self.correct("d1", {"scope": ["b"]})["id"], "d1.2") + self.assertEqual(self.add("claim", "Another.")["id"], "c3") + + def test_concurrent_corrections_never_share_a_number(self): + self.add("claim", "Writes are durable.") + with ThreadPoolExecutor(max_workers=8) as pool: + ids = list(pool.map(lambda n: self.correct("c1", {"revisit": str(n)})["id"], range(8))) + self.assertEqual(sorted(ids), sorted(f"c1.{n}" for n in range(1, 9))) + + def test_question_shaped_text_is_refused(self): + self.add("decision", "The cache lives in Redis.", choice="Redis") + with self.assertRaisesRegex(ledger.LedgerError, "not ask it"): + self.correct("d1", {"text": "Where does the cache live?"}) + self.assertEqual([item["id"] for item in ledger.read(self.path)], ["d1"]) + + def test_text_that_restates_the_choice_is_refused(self): + self.add("decision", "The cache lives in Redis.", choice="Redis") + with self.assertRaisesRegex(ledger.LedgerError, "more than the choice"): + self.correct("d1", {"text": "Redis"}) + + def test_a_rationale_that_echoes_the_choice_is_refused(self): + self.add("decision", "The cache lives in Redis.", choice="Redis") + with self.assertRaisesRegex(ledger.LedgerError, "say why"): + self.correct("d1", {"rationale": "redis"}) + + def write_echoing_decision(self): + # Records written before the echo rule carry rationale == choice. + record = ledger.make_record( + "decision", "The cache lives in Redis.", choice="Redis", author="test" + ) + record["rationale"] = "Redis" + record["alternatives"] = ["Redis"] + ledger.append(self.path, record) + + def test_scope_correction_ignores_an_old_rationale_echo(self): + self.write_echoing_decision() + self.assertEqual(self.correct("d1", {"scope": ["docket/env.py"]})["id"], "d1.1") + + def test_text_correction_ignores_an_old_rationale_echo(self): + self.write_echoing_decision() + self.assertEqual(self.correct("d1", {"text": "The cache lives in Valkey."})["id"], "d1.1") + + def test_a_text_correction_that_equals_the_rationale_is_refused(self): + self.add("decision", "The cache lives in Redis.", choice="Redis", rationale="It is fast.") + with self.assertRaisesRegex(ledger.LedgerError, "say why"): + self.correct("d1", {"text": "It is fast."}) + + def test_alternatives_that_only_repeat_the_choice_are_refused(self): + self.add("decision", "The cache lives in Redis.", choice="Redis") + with self.assertRaisesRegex(ledger.LedgerError, "option the choice beat"): + self.correct("d1", {"alternatives": ["redis"]}) + + def test_a_correction_refusal_checks_the_corrected_state(self): + self.add("decision", "The cache lives in Redis.", choice="Redis") + self.correct("d1", {"rationale": "It is fast."}) + with self.assertRaisesRegex(ledger.LedgerError, "say why"): + self.correct("d1", {"text": "It is fast."}) + + if __name__ == "__main__": unittest.main() From d51b4e71686468464b1844c29636a62ede833546 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:42:28 +0300 Subject: [PATCH 04/12] feat(cli): docket correct, and show for corrections Signed-off-by: NovusEdge --- docket/cli/__init__.py | 5 +- docket/cli/completion.py | 1 + docket/cli/correct.py | 100 +++++++++++++++++++++++++++++++++ docket/cli/query.py | 26 ++++++++- tests/test_correct_cli.py | 114 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 docket/cli/correct.py create mode 100644 tests/test_correct_cli.py diff --git a/docket/cli/__init__.py b/docket/cli/__init__.py index 69dd952..3fb351e 100644 --- a/docket/cli/__init__.py +++ b/docket/cli/__init__.py @@ -7,6 +7,7 @@ from docket.cli.admin import cmd_check, cmd_init, cmd_migrate, cmd_rebase from docket.cli.completion import cmd_completion from docket.cli.construct import cmd_construct +from docket.cli.correct import add_correct_parser from docket.cli.context_cmd import CONTEXT_ENVELOPES, cmd_context from docket.cli.feature_parser import add_feature_parser from docket.cli.graph import cmd_graph @@ -53,7 +54,7 @@ def main(argv: list[str] | None = None) -> int: sub = p.add_subparsers( dest="cmd", metavar=( - "{claim,decision,question,list,show,graph,context,where,check," + "{claim,decision,question,correct,list,show,graph,context,where,check," "rebase,migrate,init,feature,completion,update}" ), ) @@ -78,6 +79,8 @@ def main(argv: list[str] | None = None) -> int: _add_shared_args(qu) qu.set_defaults(func=cmd_question) + add_correct_parser(sub) + ls = sub.add_parser("list", help="list records") ls.add_argument("--kind", choices=KINDS) ls.add_argument( diff --git a/docket/cli/completion.py b/docket/cli/completion.py index 5f0d3a4..be437e4 100644 --- a/docket/cli/completion.py +++ b/docket/cli/completion.py @@ -52,6 +52,7 @@ "claim", "decision", "question", + "correct", "list", "show", "graph", diff --git a/docket/cli/correct.py b/docket/cli/correct.py new file mode 100644 index 0000000..ea59067 --- /dev/null +++ b/docket/cli/correct.py @@ -0,0 +1,100 @@ +import argparse +import sys + +from docket import corrections, env +from docket.ledger import LedgerError, append, parse_evidence + +# Flags that change what a record commits to. Accepted by the parser only so +# the refusal can name supersession instead of argparse's generic error. +FIXED_FLAGS = { + "choice": "--choice", + "state": "--state", + "supports": "--supports", + "depends_on": "--depends-on", + "answers": "--answers", + "supersedes": "--supersedes", +} +CLEARABLE = ("scope", "evidence", "alternatives") + + +def _fields(args: argparse.Namespace) -> dict: + fields: dict = {} + if args.text is not None: + fields["text"] = args.text + if args.rationale is not None: + fields["rationale"] = args.rationale + if args.scope: + fields["scope"] = args.scope + if args.evidence: + fields["evidence"] = [parse_evidence(item) for item in args.evidence] + if args.revisit is not None: + fields["revisit"] = args.revisit + if args.cost is not None: + fields["cost_if_wrong"] = args.cost + if args.pin: + fields["pinned"] = True + if args.unpin: + fields["pinned"] = False + if args.alternative: + fields["alternatives"] = args.alternative + if args.decided_by is not None: + fields["decided_by"] = args.decided_by + for name in args.clear: + if name in fields: + raise LedgerError(f"docket: --clear {name} and --{name} conflict") + fields[name] = [] + return fields + + +def cmd_correct(args: argparse.Namespace) -> int: + fixed = [flag for dest, flag in FIXED_FLAGS.items() if getattr(args, dest) is not None] + if fixed: + print( + f"docket: a correction cannot change {', '.join(fixed)}; record a " + f"restatement with --supersedes {args.id} instead", + file=sys.stderr, + ) + return 1 + try: + fields = _fields(args) + if not fields: + raise LedgerError("docket: name at least one field to correct") + entry = append( + env.ledger_path(), + corrections.make( + args.id, + fields, + reason=args.reason, + author=env.resolved_author(), + session=env.session_id(), + branch=env.branch(env.project_root()), + ), + ) + except LedgerError as exc: + print(str(exc), file=sys.stderr) + return 1 + print(f"{entry['id']} corrects {entry['corrects']}: {', '.join(sorted(fields))}") + return 0 + + +def add_correct_parser(sub) -> None: + co = sub.add_parser("correct", help="fix a record's wording or metadata, keeping its id") + co.add_argument("id", help="the claim, decision, or question to correct") + co.add_argument("--text") + co.add_argument("--rationale") + co.add_argument("--scope", action="append", default=[]) + co.add_argument("--evidence", action="append", default=[]) + co.add_argument("--revisit") + co.add_argument("--cost") + pin = co.add_mutually_exclusive_group() + pin.add_argument("--pin", action="store_true") + pin.add_argument("--unpin", action="store_true") + co.add_argument("--alternative", action="append", default=[]) + co.add_argument("--decided-by") + co.add_argument( + "--clear", action="append", default=[], choices=CLEARABLE, help="set a list field to empty" + ) + co.add_argument("--reason", default="", help="why the record was wrong") + for dest, flag in FIXED_FLAGS.items(): + co.add_argument(flag, dest=dest, help=argparse.SUPPRESS) + co.set_defaults(func=cmd_correct) diff --git a/docket/cli/query.py b/docket/cli/query.py index 40ecbad..e8065ac 100644 --- a/docket/cli/query.py +++ b/docket/cli/query.py @@ -12,7 +12,7 @@ import sys import textwrap -from docket import env +from docket import corrections, env from docket.cli.term import _DIM, _STATE_COLOR, _c, _match, _use_color from docket.context_model import positions from docket.env import LEDGER, justification_sets, read, retired_by @@ -110,6 +110,8 @@ def cmd_show(args: argparse.Namespace) -> int: at = positions(raw) cutoff = at[args.at] raw = [item for item in raw if at[item["id"]] <= cutoff] + if corrections.split_id(args.id): + return _show_correction(raw, args.id, args.json) entries = project(raw, validated=True) by_id = {e.get("id"): e for e in entries} e = by_id.get(args.id) @@ -163,6 +165,8 @@ def field(label: str, value: str) -> None: field("Decided by", e["decided_by"]) if e.get("cost_if_wrong"): field("Cost if wrong", e["cost_if_wrong"]) + if e.get("corrections"): + field("Corrections", ", ".join(e["corrections"])) field("Recorded state", e.get("recorded_state", e.get("state", ""))) print( f" Author: {e.get('author', '')} Session: {e.get('session', '')} Branch: {e.get('branch', '')}" @@ -170,6 +174,26 @@ def field(label: str, value: str) -> None: return 0 +def _show_correction(raw: list, ident: str, as_json: bool) -> int: + at = next((index for index, item in enumerate(raw) if item["id"] == ident), None) + if at is None: + print(f"docket: no entry {ident}", file=sys.stderr) + return 1 + line = raw[at] + target = next(i for i in corrections.fold(raw[:at]) if i["id"] == line["corrects"]) + before = {field: target.get(field) for field in line["fields"]} + if as_json: + print(json.dumps({**line, "before": before}, indent=2)) + return 0 + print(f"{ident} correction corrects {line['corrects']}") + for field, value in line["fields"].items(): + print(f" {field}: {json.dumps(before[field])} -> {json.dumps(value)}") + if line["reason"]: + print(f" Reason: {line['reason']}") + print(f" Author: {line['author']} Session: {line['session']} Branch: {line['branch']}") + return 0 + + def cmd_where(args: argparse.Namespace) -> int: path = env.ledger_path() kind = "project" if LEDGER.name in str(path) and ".claude" not in str(path) else "global" diff --git a/tests/test_correct_cli.py b/tests/test_correct_cli.py new file mode 100644 index 0000000..dec7413 --- /dev/null +++ b/tests/test_correct_cli.py @@ -0,0 +1,114 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from docket import ledger # noqa: E402 + +DOCKET = str(Path(__file__).resolve().parent.parent / "bin" / "docket") + + +def run(cwd, *args): + env = dict(os.environ) + env["DOCKET_HOME"] = str(Path(cwd) / "global") + env["DOCKET_AUTHOR"] = "test" + env["DOCKET_NO_UPDATE_CHECK"] = "1" + env["XDG_STATE_HOME"] = str(Path(cwd) / "state") + return subprocess.run( + [sys.executable, DOCKET, *args], cwd=cwd, env=env, capture_output=True, text=True + ) + + +class CorrectCommandTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.cwd = self.tmp.name + out = run(self.cwd, "decision", "The cache lives in Redis.", "--choice", "Redis", + "--scope", "lib/cache.py") + self.assertEqual(out.returncode, 0, out.stderr) + + def tearDown(self): + self.tmp.cleanup() + + def show(self, ident): + out = run(self.cwd, "show", ident, "--json") + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def test_correct_replaces_a_field_and_keeps_the_id(self): + out = run(self.cwd, "correct", "d1", "--scope", "docket/cache.py", "--reason", "moved") + self.assertEqual(out.returncode, 0, out.stderr) + self.assertTrue(out.stdout.startswith("d1.1")) + record = self.show("d1") + self.assertEqual(record["scope"], ["docket/cache.py"]) + self.assertEqual(record["corrections"], ["d1.1"]) + self.assertEqual(record["original"], {"scope": ["lib/cache.py"]}) + + def test_clear_empties_a_list_field(self): + run(self.cwd, "correct", "d1", "--clear", "scope") + self.assertEqual(self.show("d1")["scope"], []) + + def test_clear_and_set_of_one_field_conflict(self): + out = run(self.cwd, "correct", "d1", "--clear", "scope", "--scope", "a.py") + self.assertEqual(out.returncode, 1) + self.assertIn("conflict", out.stderr) + + def test_a_correction_needs_a_field(self): + out = run(self.cwd, "correct", "d1") + self.assertEqual(out.returncode, 1) + self.assertIn("at least one field", out.stderr) + + def test_choice_is_refused_with_a_pointer_to_supersession(self): + out = run(self.cwd, "correct", "d1", "--choice", "Postgres") + self.assertEqual(out.returncode, 1) + self.assertIn("--supersedes", out.stderr) + self.assertNotIn("corrections", self.show("d1")) + + def test_question_text_is_refused(self): + out = run(self.cwd, "correct", "d1", "--text", "Where does the cache live?") + self.assertEqual(out.returncode, 1) + self.assertIn("not ask it", out.stderr) + + def test_unknown_target_is_refused(self): + out = run(self.cwd, "correct", "d9", "--scope", "a.py") + self.assertEqual(out.returncode, 1) + self.assertIn("unknown or later", out.stderr) + + def test_pin_and_unpin(self): + run(self.cwd, "correct", "d1", "--pin") + self.assertTrue(self.show("d1")["pinned"]) + run(self.cwd, "correct", "d1", "--unpin") + self.assertFalse(self.show("d1")["pinned"]) + + def test_show_prints_the_correction_line(self): + run(self.cwd, "correct", "d1", "--scope", "docket/cache.py", "--reason", "moved") + out = run(self.cwd, "show", "d1.1") + self.assertEqual(out.returncode, 0, out.stderr) + self.assertIn("corrects d1", out.stdout) + self.assertIn('["lib/cache.py"] -> ["docket/cache.py"]', out.stdout) + self.assertIn("Reason: moved", out.stdout) + data = self.show("d1.1") + self.assertEqual(data["before"], {"scope": ["lib/cache.py"]}) + + def test_show_lists_corrections_on_the_record(self): + run(self.cwd, "correct", "d1", "--scope", "a.py") + run(self.cwd, "correct", "d1", "--scope", "b.py") + out = run(self.cwd, "show", "d1") + self.assertIn("Corrections: d1.1, d1.2", out.stdout) + + def test_show_at_a_point_before_the_correction(self): + run(self.cwd, "claim", "Writes are durable.") + run(self.cwd, "correct", "d1", "--scope", "a.py") + self.assertEqual(self.show("d1")["scope"], ["a.py"]) + out = run(self.cwd, "show", "d1", "--json", "--at", "c2") + self.assertEqual(json.loads(out.stdout)["scope"], ["lib/cache.py"]) + out = run(self.cwd, "show", "d1", "--json", "--at", "d1.1") + self.assertEqual(json.loads(out.stdout)["scope"], ["a.py"]) + + +if __name__ == "__main__": + unittest.main() From 410573b2d51c26563d56247401a56453da5f3636 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:44:28 +0300 Subject: [PATCH 05/12] feat: check and rebase understand correction lines Signed-off-by: NovusEdge --- docket/cli/admin.py | 10 +++++++++ docket/rebase.py | 14 ++++++++++--- tests/test_correct_cli.py | 43 +++++++++++++++++++++++++++++++++++++++ tests/test_rebase.py | 26 +++++++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/docket/cli/admin.py b/docket/cli/admin.py index 316839f..0225488 100644 --- a/docket/cli/admin.py +++ b/docket/cli/admin.py @@ -152,6 +152,16 @@ def cmd_check(args: argparse.Namespace) -> int: faults.append(f"line {number}: invalid JSON: {exc.msg}") continue count += 1 + if isinstance(record, dict) and record.get("kind") == "correction": + # Kept out of seen: a feature that names a correction id names + # nothing a brief can attach, and the feature check reports it. + try: + checked = validate_record(record, prefix=prefix) + except LedgerError as exc: + faults.append(f"line {number}: {str(exc).removeprefix('docket: ')}") + else: + prefix.add(checked) + continue ident = str(record.get("id", "")) if isinstance(record, dict) else "" match = ID_RE.fullmatch(ident) if not match: diff --git a/docket/rebase.py b/docket/rebase.py index 8d1f178..dbca98d 100644 --- a/docket/rebase.py +++ b/docket/rebase.py @@ -18,6 +18,7 @@ from collections.abc import Mapping, Sequence from typing import Any +from docket import corrections from docket.ledger import ID_RE, allocate_id @@ -81,9 +82,16 @@ def renumber( mapping: dict[str, str] = {} for record in tail: old = str(record.get("id", "")) - if not ID_RE.fullmatch(old): - raise RebaseError(f"malformed id {old!r} in the incoming tail") - new = allocate_id(allocated, str(record.get("kind"))) + if record.get("kind") == corrections.KIND: + # Counted against allocated, which holds mine and the tail so far, + # so two incoming corrections of one record take distinct numbers. + target = mapping.get(str(record.get("corrects")), str(record.get("corrects"))) + record["corrects"] = target + new = corrections.allocate(allocated, target) + else: + if not ID_RE.fullmatch(old): + raise RebaseError(f"malformed id {old!r} in the incoming tail") + new = allocate_id(allocated, str(record.get("kind"))) mapping[old] = new record["id"] = new allocated.append(record) diff --git a/tests/test_correct_cli.py b/tests/test_correct_cli.py index dec7413..9035986 100644 --- a/tests/test_correct_cli.py +++ b/tests/test_correct_cli.py @@ -110,5 +110,48 @@ def test_show_at_a_point_before_the_correction(self): self.assertEqual(json.loads(out.stdout)["scope"], ["a.py"]) +class CheckTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.cwd = self.tmp.name + subprocess.run(["git", "init", "-q"], cwd=self.cwd, check=True) + subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", + "--allow-empty", "-m", "init"], + cwd=self.cwd, check=True, + ) + run(self.cwd, "decision", "The cache lives in Redis.", "--choice", "Redis") + run(self.cwd, "correct", "d1", "--scope", "a.py") + self.path = Path(run(self.cwd, "where").stdout.split(" (")[0]) + + def tearDown(self): + self.tmp.cleanup() + + def test_check_accepts_a_correction(self): + out = run(self.cwd, "check") + self.assertEqual(out.returncode, 0, out.stdout) + self.assertIn("reads cleanly", out.stdout) + + def test_check_reports_a_malformed_correction(self): + with self.path.open("a") as stream: + stream.write(json.dumps({"schema": 2, "kind": "correction", "id": "d1.1", + "corrects": "d1", "fields": {"choice": "x"}, "reason": "", + "ts": "", "author": "", "session": "", "branch": ""}) + "\n") + out = run(self.cwd, "check") + self.assertEqual(out.returncode, 1) + self.assertIn("line 3", out.stdout) + + def test_check_reports_a_feature_naming_a_correction(self): + store = self.path.parent / "features.jsonl" + out = run(self.cwd, "feature", "start", "cache", "--text", "Cache work", "--path", "src/") + self.assertEqual(out.returncode, 0, out.stderr) + out = run(self.cwd, "feature", "amend", "cache", "--include", "d1.1") + self.assertEqual(out.returncode, 0, out.stderr) + out = run(self.cwd, "check") + self.assertEqual(out.returncode, 1) + self.assertIn("include names d1.1", out.stdout) + self.assertTrue(store.exists()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rebase.py b/tests/test_rebase.py index 390cb21..aff7ad7 100644 --- a/tests/test_rebase.py +++ b/tests/test_rebase.py @@ -83,6 +83,32 @@ def test_identical_histories_produce_an_empty_tail(self): self.assertEqual(tail, []) self.assertEqual(mapping, {}) + def test_an_incoming_correction_follows_its_renumbered_record(self): + from docket import corrections + + shared = make_record("claim", "Shared.", author="t", record_id="c1") + mine = [shared, make_record("claim", "Mine.", author="t", record_id="c2")] + theirs_record = make_record("claim", "Theirs.", author="t", record_id="c2") + fix = corrections.make("c2", {"scope": ["a.py"]}, author="t") + fix["id"] = "c2.1" + tail, mapping = renumber(mine, [shared, theirs_record, fix]) + self.assertEqual(mapping, {"c2": "c3", "c2.1": "c3.1"}) + self.assertEqual(tail[1]["corrects"], "c3") + + def test_both_branches_corrected_one_record(self): + from docket import corrections + + shared = make_record("claim", "Shared.", author="t", record_id="c1") + mine_fix = corrections.make("c1", {"scope": ["a.py"]}, author="t") + mine_fix["id"] = "c1.1" + their_fix = corrections.make("c1", {"scope": ["b.py"]}, author="t") + their_fix["id"] = "c1.1" + their_second = corrections.make("c1", {"revisit": "Later."}, author="t") + their_second["id"] = "c1.2" + tail, mapping = renumber([shared, mine_fix], [shared, their_fix, their_second]) + self.assertEqual(mapping, {"c1.1": "c1.2", "c1.2": "c1.3"}) + self.assertEqual([item["corrects"] for item in tail], ["c1", "c1"]) + if __name__ == "__main__": unittest.main() From b4b758698c211da5c3f294c1d981868f933eac46 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:46:42 +0300 Subject: [PATCH 06/12] style: satisfy ruff import order and unused-import lint Signed-off-by: NovusEdge --- docket/cli/__init__.py | 2 +- tests/test_correct_cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docket/cli/__init__.py b/docket/cli/__init__.py index 3fb351e..ff10c01 100644 --- a/docket/cli/__init__.py +++ b/docket/cli/__init__.py @@ -7,8 +7,8 @@ from docket.cli.admin import cmd_check, cmd_init, cmd_migrate, cmd_rebase from docket.cli.completion import cmd_completion from docket.cli.construct import cmd_construct -from docket.cli.correct import add_correct_parser from docket.cli.context_cmd import CONTEXT_ENVELOPES, cmd_context +from docket.cli.correct import add_correct_parser from docket.cli.feature_parser import add_feature_parser from docket.cli.graph import cmd_graph from docket.cli.query import cmd_list, cmd_show, cmd_where diff --git a/tests/test_correct_cli.py b/tests/test_correct_cli.py index 9035986..91aa230 100644 --- a/tests/test_correct_cli.py +++ b/tests/test_correct_cli.py @@ -7,7 +7,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from docket import ledger # noqa: E402 +from docket import ledger # noqa: E402,F401 DOCKET = str(Path(__file__).resolve().parent.parent / "bin" / "docket") From 63f2ffb2519bdaf01de386f47e01fd38e79c4fab Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:51:02 +0300 Subject: [PATCH 07/12] feat(context): tag corrected records and report corrections in the delta Signed-off-by: NovusEdge --- docket/cli/context_cmd.py | 5 ++- docket/context.py | 5 ++- docket/context_delta.py | 32 ++++++++++++++----- docket/context_render.py | 2 ++ tests/test_context.py | 64 +++++++++++++++++++++++++++++++++++++ tests/test_docket.py | 2 +- tests/test_feature_brief.py | 13 ++++++++ 7 files changed, 112 insertions(+), 11 deletions(-) diff --git a/docket/cli/context_cmd.py b/docket/cli/context_cmd.py index 0b7f1e3..fb68ccb 100644 --- a/docket/cli/context_cmd.py +++ b/docket/cli/context_cmd.py @@ -148,6 +148,7 @@ def cmd_context(args: argparse.Namespace) -> int: projected, since=args.since, baseline=project(prefix), + raw=raw, max_chars=args.max_chars, ledger=str(env.ledger_path()), settings=settings, @@ -168,7 +169,8 @@ def cmd_context(args: argparse.Namespace) -> int: if forced or default_on: files = tuple(dict.fromkeys(files + auto_scope_files(settings["auto_scope"]["limit"]))) try: - entries = project(read(env.ledger_path()), validated=True) + raw = read(env.ledger_path()) + entries = project(raw, validated=True) text = render_context( entries, query=args.query or "", @@ -179,6 +181,7 @@ def cmd_context(args: argparse.Namespace) -> int: settings=settings, settings_id=settings_id, feature=_feature_block(env.project_root(), entries), + latest_id=raw[-1]["id"] if raw else "", ) except (LedgerError, OSError) as exc: print(str(exc), file=sys.stderr) diff --git a/docket/context.py b/docket/context.py index 0521261..05e1345 100644 --- a/docket/context.py +++ b/docket/context.py @@ -82,6 +82,7 @@ def build_context( settings: Mapping[str, Any] | None = None, settings_id: str = "default", feature: str = "", + latest_id: str = "", ) -> str: """Render whole records under tiered budget rules. @@ -153,11 +154,13 @@ def build_context( adjacency[ident].add(target) adjacency[target].add(ident) revision = _revision(history) - latest = max(by_id, key=lambda ident: at[ident], default="") + latest = latest_id or max(by_id, key=lambda ident: at[ident], default="") if latest: # The digest covers the history up to and including that record, which # here is the whole history. A rebase renumbers the tail, so an agent # that passes the pair back to --since learns its baseline is stale. + # A correction can be the last line, and projection drops correction + # lines, so the caller passes the raw last id. latest = f"{latest}@{revision}" feature_prefix = _feature_prefix(feature, max(1, soft_limit // 4)) prefix = ( diff --git a/docket/context_delta.py b/docket/context_delta.py index f2c92c8..f51eabf 100644 --- a/docket/context_delta.py +++ b/docket/context_delta.py @@ -21,6 +21,7 @@ def build_delta( *, since: str, baseline: Iterable[Mapping[str, Any]], + raw: Iterable[Mapping[str, Any]] | None = None, max_chars: int | None = None, ledger: str = "", settings: Mapping[str, Any] | None = None, @@ -38,39 +39,54 @@ def build_delta( """ history = list(entries) + baseline = list(baseline) by_id = {_id(item): item for item in history} - at = positions(history) + lines = list(raw) if raw is not None else history + at = positions(lines) since, _, expected = since.partition("@") - if since not in by_id: + if since not in at: return None cutoff = at[since] - prefix = [item for item in history if at[_id(item)] <= cutoff] - if expected and _revision(prefix) != expected: + # The token was minted when since was the last line, so the baseline, the + # projection of lines up to since, is exactly the history it hashed. + if expected and _revision(baseline) != expected: # A rebase renumbers the tail, so this ID now covers different history. return None was_available = {_id(item) for item in baseline if _available(item)} cfg = settings if settings is not None else _SETTINGS_DEFAULTS limit = max_chars if max_chars is not None else cfg["budget"]["target"] + corrected_ids = { + str(line["corrects"]) + for line in lines + if line.get("kind") == "correction" + and at[_id(line)] > cutoff + and at.get(str(line["corrects"]), cutoff + 1) <= cutoff + } added = [item for item in history if at[_id(item)] > cutoff] + corrected = [item for item in history if _id(item) in corrected_ids] changed = [ item for item in history - if at[_id(item)] <= cutoff and _id(item) in was_available and not _available(item) + if at[_id(item)] <= cutoff + and _id(item) in was_available + and not _available(item) + and _id(item) not in corrected_ids ] - latest = max(by_id, key=lambda ident: at[ident], default="") + latest = _id(lines[-1]) if lines else "" revision = _revision(history) head = ( "\n".join( [ f"# docket: {_clip_metadata(ledger or 'ledger', 180)} | revision: {revision}" f" | latest: {latest}@{revision} | since: {since}", - f"# changed: {len(added)} added, {len(changed)} no longer available.", + f"# changed: {len(added)} added, {len(corrected)} corrected, " + f"{len(changed)} no longer available.", ] ) + "\n\n" ) blocks: list[str] = [] - for item in added + changed: + for item in added + corrected + changed: block = _render_record(item, "changed", "", by_id) if len(head) + len("\n\n".join(blocks + [block])) > limit: block = _index_line(item, cfg["index"]["detail_min"]) diff --git a/docket/context_render.py b/docket/context_render.py index 3516cc8..1cab1d5 100644 --- a/docket/context_render.py +++ b/docket/context_render.py @@ -142,6 +142,8 @@ def _render_record( tags.append("pinned") if _is_retired(entry): tags.append("historical") + if _list(entry.get("corrections")): + tags.append("corrected") lines = [f"### {ident} | {kind} | {state} [{', '.join(tags)}]", f"role: {role}"] lines.append(f"text: {_text(entry.get('text'))}") if recorded_state != state: diff --git a/tests/test_context.py b/tests/test_context.py index 416d515..5c398db 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -914,6 +914,70 @@ def test_delta_refuses_a_baseline_whose_digest_no_longer_matches(self): build_delta(history, since=stale, baseline=projected(records), ledger="repo") ) + def correction(self, ident, target, fields): + from docket import corrections + + line = corrections.make(target, fields, author="test", ts="2026-09-23T00:00:00+00:00") + line["id"] = ident + return line + + def test_a_corrected_record_is_tagged(self): + raw = [ + entry("c1", "claim", "A premise", state="accepted"), + self.correction("c1.1", "c1", {"rationale": "Measured."}), + ] + rendered = build_context(project(raw, validated=True), ledger="repo", latest_id="c1.1") + self.assertRegex(rendered, r"### c1 \| claim \| accepted \[[^\]]*corrected") + + def test_since_a_correction_token_yields_a_delta(self): + raw = [ + entry("c1", "claim", "A premise", state="accepted"), + self.correction("c1.1", "c1", {"rationale": "Measured."}), + ] + history = project(raw, validated=True) + rendered = build_context(history, ledger="repo", latest_id="c1.1") + token = re.search(r"latest: (c1\.1@[0-9a-f]+)", rendered).group(1) + delta = build_delta(history, since=token, baseline=history, raw=raw, ledger="repo") + self.assertIsNotNone(delta) + self.assertIn("0 added, 0 corrected, 0 no longer available", delta) + + def test_delta_reports_a_correction_after_the_baseline(self): + raw = [ + entry("c1", "claim", "A premise", state="accepted"), + entry("c2", "claim", "Another premise", state="accepted"), + self.correction("c1.1", "c1", {"rationale": "Measured."}), + ] + delta = build_delta( + project(raw, validated=True), + since="c2", + baseline=project(raw[:2], validated=True), + raw=raw, + ledger="repo", + ) + self.assertIn("### c1 ", delta) + self.assertIn("0 added, 1 corrected, 0 no longer available", delta) + + def test_the_three_delta_lists_stay_disjoint(self): + raw = [ + entry("c1", "claim", "A premise", state="accepted"), + entry("d2", "decision", "Serve from the cache", choice="serve", depends_on=("c1",)), + entry("c3", "claim", "Replace the premise", state="accepted", supersedes=("c1",)), + self.correction("c1.1", "c1", {"rationale": "Measured."}), + self.correction("c3.1", "c3", {"rationale": "Measured."}), + ] + delta = build_delta( + project(raw, validated=True), + since="d2", + baseline=project(raw[:2], validated=True), + raw=raw, + ledger="repo", + ) + # c3 is added, not corrected. c1 is corrected, not also unavailable. + # d2 lost its prerequisite, and a retirement is no correction. + self.assertIn("1 added, 1 corrected, 1 no longer available", delta) + self.assertEqual(delta.count("### c1 "), 1) + self.assertEqual(delta.count("### c3 "), 1) + class FeatureHeaderTests(unittest.TestCase): def entries(self): diff --git a/tests/test_docket.py b/tests/test_docket.py index ddcdb76..0481809 100644 --- a/tests/test_docket.py +++ b/tests/test_docket.py @@ -597,7 +597,7 @@ def test_since_builds_the_baseline_from_file_position_not_id_number(self): stdout = out.getvalue() self.assertIn("since: d1", stdout) self.assertIn("### c8 ", stdout) - self.assertIn("1 added, 1 no longer available", stdout) + self.assertIn("1 added, 0 corrected, 1 no longer available", stdout) class InitTests(unittest.TestCase): diff --git a/tests/test_feature_brief.py b/tests/test_feature_brief.py index 913adc1..4f89f41 100644 --- a/tests/test_feature_brief.py +++ b/tests/test_feature_brief.py @@ -100,6 +100,19 @@ def test_reasons_are_reported_on_every_attached_record(self): self.assertEqual(top["brief_specificity"], len("installer/planner.go")) self.assertEqual(top["brief_matches"], 1) + def test_a_scope_correction_attaches_a_record(self): + from docket import corrections, ledger + + record = ledger.make_record( + "decision", "The cache lives in Redis.", choice="Redis", author="t", + record_id="d1", scope=["lib/cache.py"], + ) + fix = corrections.make("d1", {"scope": ["docket/cache.py"]}, author="t") + fix["id"] = "d1.1" + entries = ledger.project([record, fix]) + self.assertEqual([item["id"] for item in brief.attach(entries, ["docket/cache.py"])], ["d1"]) + self.assertEqual(brief.attach(entries, ["lib/cache.py"]), []) + class ExpandTests(unittest.TestCase): def setUp(self): From b93d250b0f5ef214a6f55901bf6d8d01253ccac8 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 11:53:54 +0300 Subject: [PATCH 08/12] docs: document ledger corrections Signed-off-by: NovusEdge --- CHANGELOG.md | 4 ++++ docs/commands.md | 20 ++++++++++++++++++++ docs/ledger.md | 30 ++++++++++++++++++++++++++++++ skills/docket/SKILL.md | 12 ++++++++++++ 4 files changed, 66 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d8980..d560e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `docket correct` fixes a record's wording or metadata while it keeps its id. Corrections are ledger lines, `docket show` lists them, and briefings tag corrected records. A ledger that holds a correction needs this release or later to read. + ### Changed - `docket claim` and `docket decision` refuse a text that ends in `?`. A diff --git a/docs/commands.md b/docs/commands.md index d89ec79..e03bd57 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -52,6 +52,26 @@ prerequisite must be current and accepted. A decision with missing prerequisites remains recorded as adopted but reports that it is blocked. See the [relationship reference](ledger.md#relations). +`docket correct ID` + +| Flag | Effect | +|---|---| +| `--text T`, `--rationale R` | Replace the record's text or rationale | +| `--scope PATH` | Replace the scope list. Repeat for more than one value. | +| `--evidence REF` | Replace the evidence list. Repeat for more than one value. | +| `--revisit R`, `--cost C` | Replace the revisit note or cost-if-wrong | +| `--pin`, `--unpin` | Replace the pinned flag | +| `--alternative A` | Decision only. Replace the alternatives list. Repeat for more. | +| `--decided-by WHO` | Decision only. Replace who made the call. | +| `--clear scope\|evidence\|alternatives` | Empty a list instead of replacing it. Repeat for more than one field. | +| `--reason R` | Why the record was wrong | + +Fix a record that was written down wrong. The record keeps its id, and a +repeated flag replaces the whole list it names. The command refuses +`--choice`, `--state`, and the relation flags; supersede the record instead +to change those. `docket show ID` lists a record's corrections, and `docket +show ID.N` prints one correction with the values it replaced. + ## Reading `docket list` diff --git a/docs/ledger.md b/docs/ledger.md index 4b43130..aa875a0 100644 --- a/docs/ledger.md +++ b/docs/ledger.md @@ -116,6 +116,36 @@ For decisions, `decided_by` can attribute the commitment to another person or agent. Both values are self-reported; Docket does not authenticate these identities. +## Correction lines + +A correction line fixes the wording or metadata of an earlier record. The +record keeps its id, so relations, feature includes, and citations that name +it stay valid. + + {"schema":2,"kind":"correction","id":"d12.1","corrects":"d12", + "fields":{"scope":["docket/env.py"]},"reason":"scope named the old path", + "ts":"...","author":"...","session":"...","branch":"..."} + +| Field | Meaning | +|---|---| +| `id` | `.`; `n` counts that record's corrections from 1 and must increase | +| `corrects` | the record id; must equal the id's base and name an earlier record | +| `fields` | replacement values; each replaces the record's value whole | +| `reason` | why the record was wrong; may be empty | + +A correction may replace `text`, `rationale`, `scope`, `cost_if_wrong`, +`evidence`, `revisit`, and `pinned`, and on a decision also `alternatives` +and `decided_by`. It may not replace `choice`, the state, a relation, the +kind, or provenance: those change what the record commits to, so record a +restatement with `--supersedes` instead. + +Nothing may point at a correction id. Commands that read the ledger apply +corrections in file order; `docket show ID --json` carries `corrections` and +`original`, the values before the first correction. + +A docket release older than the one that introduced corrections refuses a +ledger that holds a correction line. + ## Relations ### Supports diff --git a/skills/docket/SKILL.md b/skills/docket/SKILL.md index b9cb541..d2d6d84 100644 --- a/skills/docket/SKILL.md +++ b/skills/docket/SKILL.md @@ -106,6 +106,18 @@ The common options are `--scope` (repeatable), `--rationale`, `--supports`, `--depends-on`, `--answers`, `--supersedes`, `--evidence` (repeatable), `--revisit`, `--cost`, and `--pin`. +## Correct a record + +Use `docket correct` when a record was written down wrong and its commitment +still holds: a wrong scope, a question-shaped headline, a stale rationale. + + docket correct d12 --scope docket/env.py --reason "scope named the old path" + +The record keeps its id, so records that support it keep their grounds. Use +`--supersedes` instead when the commitment itself changed: a correction +cannot change `choice`, the state, or a relation. A briefing tags a corrected +record `corrected`; `docket show ID` lists the corrections. + ## What recording refuses, and what it only warns about Four forms are refused outright: From 3d2bb2bc4aa51511789855bea1fcc7e9c9f8f514 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 12:11:11 +0300 Subject: [PATCH 09/12] fix(corrections): drop no-op fields, split decision-only refusal, skip write-time checks on rebase A correction whose replacement value matches the record's current value now gets dropped before the echo/question-text checks run; one left empty is refused as a no-op. A decision-only field named on a claim or question now gets its own message instead of the supersession pointer. These refusals, and the no-op drop, run only for a correction that arrived with no id (the CLI path); a pre-numbered correction from rebase skips them, matching how a pre-numbered record already does. Signed-off-by: NovusEdge --- docket/corrections.py | 38 +++++++++++++++++++++++++++----------- docket/ledger.py | 13 +++++++++---- tests/test_corrections.py | 25 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/docket/corrections.py b/docket/corrections.py index f2d3f8b..cecc08f 100644 --- a/docket/corrections.py +++ b/docket/corrections.py @@ -63,13 +63,18 @@ def validate(record: dict[str, Any], prefix: Any) -> dict[str, Any]: target = prefix.by_id.get(target_id) if target is None: raise _error(ident, f"corrects unknown or later ID {target_id!r}") - refused = sorted(set(fields) - correctable(target["kind"])) - if refused: + fixed = sorted(set(fields) - _COMMON - _DECISION_ONLY) + if fixed: raise _error( ident, - f"cannot correct {', '.join(refused)} on a {target['kind']}; " + f"cannot correct {', '.join(fixed)} on a {target['kind']}; " "supersede the record to change it", ) + if target["kind"] != "decision": + decision_only = sorted(set(fields) & _DECISION_ONLY) + if decision_only: + noun = "field" if len(decision_only) == 1 else "fields" + raise _error(ident, f"{', '.join(decision_only)} is a decision-only {noun}") if number <= prefix.corrections.get(target_id, 0): raise _error(ident, "correction numbers must increase for each record; gaps are allowed") # The record's own type rules check each replacement value. @@ -141,17 +146,28 @@ def make( def refuse(entries: list[dict[str, Any]], correction: dict[str, Any]) -> None: - """Run the write-time refusals on the record as this correction leaves it. + """Drop no-op fields and run the write-time refusals on what remains. - Only the fields the correction replaces are checked, against the record - as earlier corrections left it. + A field whose replacement value equals the record's current projected + value changes nothing, so it is dropped before the echo and question-text + refusals run and before the reduced fields are written. A correction left + with no field is itself refused: it would append a line that changes + nothing the reader can see. + + Only the fields the correction still carries are checked, against the + record as earlier corrections left it. """ - from docket.ledger import _reject_empty_reasoning, _reject_question_text + from docket.ledger import _error, _reject_empty_reasoning, _reject_question_text - fields = correction["fields"] target = next(item for item in fold(entries) if item["id"] == correction["corrects"]) - record = {**target, **fields} - if "text" in fields: + reduced = { + field: value for field, value in correction["fields"].items() if target.get(field) != value + } + if not reduced: + raise _error(correction["id"], "nothing to correct: every field already has that value") + correction["fields"] = reduced + record = {**target, **reduced} + if "text" in reduced: _reject_question_text(record) if record["kind"] == "decision": - _reject_empty_reasoning(record, frozenset(fields)) + _reject_empty_reasoning(record, frozenset(reduced)) diff --git a/docket/ledger.py b/docket/ledger.py index 5d8d195..eb9cb8f 100644 --- a/docket/ledger.py +++ b/docket/ledger.py @@ -714,8 +714,11 @@ def append(path: Path | str, record: dict[str, Any]) -> dict[str, Any]: The caller may supply an empty id; the record id, or a correction's `.`, is then allocated while holding the lock, preventing - duplicate IDs between writers. A correction's write-time refusals run on - the projection read under the same lock. + duplicate IDs between writers. A correction's write-time refusals, and its + no-op field drop, run on the projection read under the same lock, but only + for a correction that arrived with no id: that is the CLI path. A + pre-numbered correction, arriving through rebase, skips them, the same way + a pre-numbered record does. """ path = Path(path) with _ledger_lock(path): @@ -724,10 +727,12 @@ def append(path: Path | str, record: dict[str, Any]) -> dict[str, Any]: entries = read(path, lock=False) candidate = copy.deepcopy(record) if candidate.get("kind") == corrections.KIND: - if not candidate.get("id"): + from_cli = not candidate.get("id") + if from_cli: candidate["id"] = corrections.allocate(entries, str(candidate.get("corrects", ""))) validate_record(candidate, previous=entries) - corrections.refuse(entries, candidate) + if from_cli: + corrections.refuse(entries, candidate) else: if not candidate.get("id"): kind = candidate.get("kind") or "" diff --git a/tests/test_corrections.py b/tests/test_corrections.py index 45d3632..2db3467 100644 --- a/tests/test_corrections.py +++ b/tests/test_corrections.py @@ -69,6 +69,14 @@ def test_decision_only_fields_are_refused_on_a_claim(self): with self.assertRaisesRegex(ledger.LedgerError, "alternatives"): ledger.validate_entries([claim("c1"), line("c1.1", "c1", {"alternatives": ["x"]})]) + def test_decision_only_field_has_its_own_message(self): + with self.assertRaisesRegex(ledger.LedgerError, "alternatives is a decision-only field"): + ledger.validate_entries([claim("c1"), line("c1.1", "c1", {"alternatives": ["x"]})]) + + def test_a_fixed_field_keeps_the_supersede_message(self): + with self.assertRaisesRegex(ledger.LedgerError, "supersede the record"): + ledger.validate_entries([decision("d1"), line("d1.1", "d1", {"choice": "Postgres"})]) + def test_a_value_of_the_wrong_type_is_refused(self): with self.assertRaisesRegex(ledger.LedgerError, "scope"): ledger.validate_entries([decision("d1"), line("d1.1", "d1", {"scope": "a.py"})]) @@ -267,6 +275,23 @@ def test_a_correction_refusal_checks_the_corrected_state(self): with self.assertRaisesRegex(ledger.LedgerError, "say why"): self.correct("d1", {"text": "It is fast."}) + def test_a_no_op_field_is_refused(self): + self.add("claim", "Writes are durable.", pinned=True) + with self.assertRaisesRegex(ledger.LedgerError, "nothing to correct"): + self.correct("c1", {"pinned": True}) + + def test_a_no_op_alongside_a_real_change_writes_only_the_real_field(self): + self.add("claim", "Writes are durable.", scope=["a.py"]) + entry = self.correct("c1", {"scope": ["a.py"], "revisit": "Later."}) + self.assertEqual(entry["fields"], {"revisit": "Later."}) + + def test_a_pre_numbered_correction_skips_the_write_time_refusals(self): + self.add("decision", "The cache lives in Redis.", choice="Redis") + pre_numbered = corrections.make("d1", {"text": "Redis"}, author="test") + pre_numbered["id"] = "d1.1" + entry = ledger.append(self.path, pre_numbered) + self.assertEqual(entry["id"], "d1.1") + if __name__ == "__main__": unittest.main() From d0b6dc3f08b6e4dd8bd427e146b4ea389064dd2f Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 12:11:18 +0300 Subject: [PATCH 10/12] fix(cli): tighten correct's refusals, check's record count, and show's ts correct now rejects a malformed id ('correct d1.1') before anything else, names the real --alternative flag in a --clear conflict, and catches OSError the way record.py's append does. docket check reports correction lines separately from records ('4 records and 5 corrections'). docket show prints ts on a correction's Author line and adds a Recorded line to a record's, since the record form printed no ts at all. guard_ledger's prompt now names docket correct alongside the other sanctioned commands. Adds a context_cmd subprocess test and delta coverage for a supersession with no correction. Signed-off-by: NovusEdge --- docket/cli/admin.py | 11 +++++-- docket/cli/correct.py | 10 ++++-- docket/cli/query.py | 6 +++- hooks/guard_ledger.py | 3 +- tests/test_context.py | 11 +++++++ tests/test_correct_cli.py | 69 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/docket/cli/admin.py b/docket/cli/admin.py index 0225488..0870ae0 100644 --- a/docket/cli/admin.py +++ b/docket/cli/admin.py @@ -142,7 +142,8 @@ def cmd_check(args: argparse.Namespace) -> int: prefix = _Prefix([]) seen: dict[str, int] = {} highest = 0 - count = 0 + records = 0 + correction_lines = 0 for number, line in enumerate(lines, 1): if not line.strip(): continue @@ -151,8 +152,8 @@ def cmd_check(args: argparse.Namespace) -> int: except json.JSONDecodeError as exc: faults.append(f"line {number}: invalid JSON: {exc.msg}") continue - count += 1 if isinstance(record, dict) and record.get("kind") == "correction": + correction_lines += 1 # Kept out of seen: a feature that names a correction id names # nothing a brief can attach, and the feature check reports it. try: @@ -162,6 +163,7 @@ def cmd_check(args: argparse.Namespace) -> int: else: prefix.add(checked) continue + records += 1 ident = str(record.get("id", "")) if isinstance(record, dict) else "" match = ID_RE.fullmatch(ident) if not match: @@ -190,7 +192,10 @@ def cmd_check(args: argparse.Namespace) -> int: failed = bool(faults) if not faults: - print(f"docket: {path} reads cleanly, {count} record{'s' if count != 1 else ''}") + summary = f"{records} record{'s' if records != 1 else ''}" + if correction_lines: + summary += f" and {correction_lines} correction{'s' if correction_lines != 1 else ''}" + print(f"docket: {path} reads cleanly, {summary}") else: print(f"docket: {path} has {len(faults)} fault{'s' if len(faults) != 1 else ''}") for fault in faults: diff --git a/docket/cli/correct.py b/docket/cli/correct.py index ea59067..ddf08e3 100644 --- a/docket/cli/correct.py +++ b/docket/cli/correct.py @@ -2,7 +2,7 @@ import sys from docket import corrections, env -from docket.ledger import LedgerError, append, parse_evidence +from docket.ledger import ID_RE, LedgerError, append, parse_evidence # Flags that change what a record commits to. Accepted by the parser only so # the refusal can name supersession instead of argparse's generic error. @@ -15,6 +15,7 @@ "supersedes": "--supersedes", } CLEARABLE = ("scope", "evidence", "alternatives") +CLEAR_FLAGS = {"scope": "--scope", "evidence": "--evidence", "alternatives": "--alternative"} def _fields(args: argparse.Namespace) -> dict: @@ -41,12 +42,15 @@ def _fields(args: argparse.Namespace) -> dict: fields["decided_by"] = args.decided_by for name in args.clear: if name in fields: - raise LedgerError(f"docket: --clear {name} and --{name} conflict") + raise LedgerError(f"docket: --clear {name} and {CLEAR_FLAGS[name]} conflict") fields[name] = [] return fields def cmd_correct(args: argparse.Namespace) -> int: + if not ID_RE.fullmatch(args.id): + print("docket: correct names a claim, decision, or question id", file=sys.stderr) + return 1 fixed = [flag for dest, flag in FIXED_FLAGS.items() if getattr(args, dest) is not None] if fixed: print( @@ -70,7 +74,7 @@ def cmd_correct(args: argparse.Namespace) -> int: branch=env.branch(env.project_root()), ), ) - except LedgerError as exc: + except (LedgerError, OSError) as exc: print(str(exc), file=sys.stderr) return 1 print(f"{entry['id']} corrects {entry['corrects']}: {', '.join(sorted(fields))}") diff --git a/docket/cli/query.py b/docket/cli/query.py index e8065ac..40d0969 100644 --- a/docket/cli/query.py +++ b/docket/cli/query.py @@ -168,6 +168,7 @@ def field(label: str, value: str) -> None: if e.get("corrections"): field("Corrections", ", ".join(e["corrections"])) field("Recorded state", e.get("recorded_state", e.get("state", ""))) + print(f" Recorded: {e.get('ts', '')}") print( f" Author: {e.get('author', '')} Session: {e.get('session', '')} Branch: {e.get('branch', '')}" ) @@ -190,7 +191,10 @@ def _show_correction(raw: list, ident: str, as_json: bool) -> int: print(f" {field}: {json.dumps(before[field])} -> {json.dumps(value)}") if line["reason"]: print(f" Reason: {line['reason']}") - print(f" Author: {line['author']} Session: {line['session']} Branch: {line['branch']}") + print( + f" Author: {line['author']} Session: {line['session']} Branch: {line['branch']} " + f"Ts: {line['ts']}" + ) return 0 diff --git a/hooks/guard_ledger.py b/hooks/guard_ledger.py index 550c665..47d376d 100755 --- a/hooks/guard_ledger.py +++ b/hooks/guard_ledger.py @@ -222,7 +222,8 @@ def main() -> int: f"This writes {target} directly. A ledger is append-only and is " "validated on every read, so an edit made around the CLI can " "break it. Record with `docket claim`, `docket decision` or " - "`docket question`; repair with `docket check` and `docket " + "`docket question`; fix a recorded field with `docket correct`; " + "repair with `docket check` and `docket " "rebase`; convert an old ledger with `docket migrate`. Approve " "only if you mean to edit the file itself." ), diff --git a/tests/test_context.py b/tests/test_context.py index 5c398db..768e1f9 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -914,6 +914,17 @@ def test_delta_refuses_a_baseline_whose_digest_no_longer_matches(self): build_delta(history, since=stale, baseline=projected(records), ledger="repo") ) + def test_delta_reports_a_supersession_with_no_correction_as_unavailable_not_corrected(self): + records = [ + entry("c1", "claim", "A premise", state="accepted"), + entry("c2", "claim", "Replace the premise", state="accepted", supersedes=("c1",)), + ] + delta = build_delta( + projected(records), since="c1", baseline=projected(records[:1]), ledger="repo" + ) + self.assertIn("0 corrected", delta) + self.assertIn("1 no longer available", delta) + def correction(self, ident, target, fields): from docket import corrections diff --git a/tests/test_correct_cli.py b/tests/test_correct_cli.py index 91aa230..4b46f3b 100644 --- a/tests/test_correct_cli.py +++ b/tests/test_correct_cli.py @@ -1,5 +1,6 @@ import json import os +import re import subprocess import sys import tempfile @@ -78,6 +79,48 @@ def test_unknown_target_is_refused(self): self.assertEqual(out.returncode, 1) self.assertIn("unknown or later", out.stderr) + def test_clear_alternatives_names_the_real_flag(self): + out = run(self.cwd, "correct", "d1", "--clear", "alternatives", "--alternative", "Postgres") + self.assertEqual(out.returncode, 1) + self.assertIn("--alternative", out.stderr) + self.assertNotIn("--alternatives", out.stderr) + + def test_a_malformed_id_is_refused_before_anything_else(self): + out = run(self.cwd, "correct", "d1.1", "--scope", "a.py") + self.assertEqual(out.returncode, 1) + self.assertIn("correct names a claim, decision, or question id", out.stderr) + + def test_state_is_refused_with_a_pointer_to_supersession(self): + out = run(self.cwd, "correct", "d1", "--state", "adopted") + self.assertEqual(out.returncode, 1) + self.assertIn("--supersedes", out.stderr) + self.assertNotIn("corrections", self.show("d1")) + + def test_supersedes_is_refused_with_a_pointer_to_supersession(self): + out = run(self.cwd, "correct", "d1", "--supersedes", "d1") + self.assertEqual(out.returncode, 1) + self.assertIn("--supersedes", out.stderr) + self.assertNotIn("corrections", self.show("d1")) + + def test_decision_only_field_is_refused_on_a_claim(self): + run(self.cwd, "claim", "Writes are durable.") + out = run(self.cwd, "correct", "c2", "--alternative", "x") + self.assertEqual(out.returncode, 1) + self.assertIn("decision-only field", out.stderr) + + def test_repeating_a_pin_refuses_as_a_no_op(self): + run(self.cwd, "correct", "d1", "--pin") + out = run(self.cwd, "correct", "d1", "--pin") + self.assertEqual(out.returncode, 1) + self.assertIn("nothing to correct", out.stderr) + self.assertEqual(self.show("d1")["corrections"], ["d1.1"]) + + def test_a_no_op_field_alongside_a_real_change_writes_only_the_real_field(self): + out = run(self.cwd, "correct", "d1", "--scope", "lib/cache.py", "--rationale", "Fast.") + self.assertEqual(out.returncode, 0, out.stderr) + data = self.show("d1.1") + self.assertEqual(data["fields"], {"rationale": "Fast."}) + def test_pin_and_unpin(self): run(self.cwd, "correct", "d1", "--pin") self.assertTrue(self.show("d1")["pinned"]) @@ -110,6 +153,28 @@ def test_show_at_a_point_before_the_correction(self): self.assertEqual(json.loads(out.stdout)["scope"], ["a.py"]) +class ContextCmdTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.cwd = self.tmp.name + + def tearDown(self): + self.tmp.cleanup() + + def test_context_since_a_correction_token_reports_no_change(self): + out = run(self.cwd, "decision", "The cache lives in Redis.", "--choice", "Redis") + self.assertEqual(out.returncode, 0, out.stderr) + out = run(self.cwd, "correct", "d1", "--scope", "docket/cache.py") + self.assertEqual(out.returncode, 0, out.stderr) + out = run(self.cwd, "context", "--no-auto-scope") + self.assertEqual(out.returncode, 0, out.stderr) + self.assertIn("latest: d1.1@", out.stdout) + token = re.search(r"latest: (d1\.1@[0-9a-f]+)", out.stdout).group(1) + out = run(self.cwd, "context", "--no-auto-scope", "--since", token) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertIn("# changed: 0 added, 0 corrected, 0 no longer available.", out.stdout) + + class CheckTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -131,15 +196,17 @@ def test_check_accepts_a_correction(self): out = run(self.cwd, "check") self.assertEqual(out.returncode, 0, out.stdout) self.assertIn("reads cleanly", out.stdout) + self.assertIn("1 record and 1 correction", out.stdout) def test_check_reports_a_malformed_correction(self): with self.path.open("a") as stream: - stream.write(json.dumps({"schema": 2, "kind": "correction", "id": "d1.1", + stream.write(json.dumps({"schema": 2, "kind": "correction", "id": "d1.2", "corrects": "d1", "fields": {"choice": "x"}, "reason": "", "ts": "", "author": "", "session": "", "branch": ""}) + "\n") out = run(self.cwd, "check") self.assertEqual(out.returncode, 1) self.assertIn("line 3", out.stdout) + self.assertIn("choice", out.stdout) def test_check_reports_a_feature_naming_a_correction(self): store = self.path.parent / "features.jsonl" From c43e18e45be24ed35a070396af31e47a9669e330 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 12:15:43 +0300 Subject: [PATCH 11/12] docket: record why rebased corrections skip the write-time refusals Signed-off-by: NovusEdge --- .docket/ledger.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 2a78901..d409544 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -127,3 +127,4 @@ {"schema":2,"kind":"decision","id":"d127","text":"Question-shaped decision headlines get rewritten in place, not superseded.","state":"adopted","ts":"2026-09-23T08:04:09+00:00","author":"claude-code","session":"","branch":"main","scope":[".docket/ledger.jsonl","scripts/declarative_ledger.py"],"rationale":"Superseding would retire all 81, and 36 records declare support or prerequisites through them, so each would lose its grounds.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Any revision token held before the rewrite goes stale and falls back to a full briefing; the old headlines survive only in the backup and git history.","pinned":false,"choice":"A one-off script rewrites the text field of the 81 question-shaped decisions in place, after writing a backup beside the ledger. Ids, relations, choice, and provenance stay unchanged.","alternatives":["Supersede each decision with a declarative restatement."],"decided_by":""} {"schema":2,"kind":"decision","id":"d128","text":"Ledger records accept field-level corrections that keep the record's id.","state":"adopted","ts":"2026-09-23T08:17:31+00:00","author":"claude-code","session":"","branch":"main","scope":["docket/**","docs/ledger.md"],"rationale":"Supersession changes the id, so records that support, depend on, or cite the old id point at a retired record. d88 restating d38 left d39 on stale support. Three one-off scripts already rewrote history in place with no trail.","supports":[],"depends_on":[],"answers":[],"supersedes":["d89"],"evidence":[],"revisit":"","cost_if_wrong":"A new line kind changes the ledger format, the projection every reader uses, and the revision hash.","pinned":false,"choice":"A correction is its own ledger line, addressed as ., that changes wording and metadata of an earlier record: text, rationale, scope, cost, evidence, revisit. Relations, state, kind, and provenance stay fixed; changing those still goes through supersession.","alternatives":["Supersede a mis-recorded record with a restatement.","Add a restatement flag to supersession that moves inbound links to the new record."],"decided_by":""} {"schema":2,"kind":"decision","id":"d129","text":"Corrections are ledger lines that project() folds into the corrected record.","state":"adopted","ts":"2026-09-23T08:20:41+00:00","author":"claude-code","session":"","branch":"main","scope":["docket/ledger.py","docket/rebase.py","docket/context_delta.py","docket/cli/admin.py"],"rationale":"project() already runs after any history slice, so show --at and --since see only earlier corrections without their own change. A side file doubles merge, rebase, and hashing; folding in read() hides the original lines from rebase and check.","supports":[["d128"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Every raw-line reader must accept a fourth line kind, and older docket releases refuse a ledger holding one.","pinned":false,"choice":"A correction line (kind correction, id .) names one earlier record and a fields object of replacement values. project() applies corrections in file order, drops the lines, and adds corrections and original to the record. choice stays fixed; alternatives, decided_by, and pinned join the correctable fields.","alternatives":["A side file, .docket/corrections.jsonl, like the feature store.","Fold corrections inside read(), so raw consumers never see a correction line."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d130","text":"A correction appended with its id already set skips the write-time refusals.","state":"adopted","ts":"2026-09-23T09:15:42+00:00","author":"claude-code","session":"","branch":"feat/ledger-corrections","scope":["docket/ledger.py","docket/corrections.py","docket/rebase.py"],"rationale":"A correction valid on its own branch can be refused against the other branch's corrected state, which leaves a rebase half-applied.","supports":[["d129"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A hand-written numbered correction line bypasses the refusals; validation still bounds its fields.","pinned":false,"choice":"append runs the no-op drop and the echo and question-text refusals only for a correction the CLI submits unnumbered. A pre-numbered correction, as rebase appends, passes on validation alone, the same as a pre-numbered record.","alternatives":["Run the refusals on every appended correction, including rebased ones."],"decided_by":""} From ca505481c6373278b44f25320f8494da6c9e18cb Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 23 Sep 2026 12:42:16 +0300 Subject: [PATCH 12/12] fix: satisfy ruff format and pyrefly on the corrections branch split_id returns None for a malformed id. parts_of states the already-validated case, which pyrefly otherwise flags at each unpack. Signed-off-by: NovusEdge --- docket/corrections.py | 13 +++++++--- docket/ledger.py | 6 ++--- scripts/declarative_ledger.py | 7 +++-- tests/test_correct_cli.py | 48 +++++++++++++++++++++++++++++------ tests/test_corrections.py | 18 ++++++++----- tests/test_feature_brief.py | 12 ++++++--- 6 files changed, 78 insertions(+), 26 deletions(-) diff --git a/docket/corrections.py b/docket/corrections.py index cecc08f..775daf0 100644 --- a/docket/corrections.py +++ b/docket/corrections.py @@ -28,6 +28,14 @@ def split_id(ident: str) -> tuple[str, int] | None: return (match.group(1), int(match.group(2))) if match else None +def parts_of(ident: str) -> tuple[str, int]: + """split_id for an id that validation already accepted.""" + parts = split_id(ident) + if parts is None: + raise ValueError(f"not a correction id: {ident!r}") + return parts + + def correctable(kind: str) -> frozenset[str]: return _COMMON | _DECISION_ONLY if kind == "decision" else _COMMON @@ -41,8 +49,7 @@ def validate(record: dict[str, Any], prefix: Any) -> dict[str, Any]: from docket.ledger import SCHEMA, _error, validate_record ident = record.get("id") - parts = split_id(ident) if isinstance(ident, str) else None - if parts is None: + if not isinstance(ident, str) or (parts := split_id(ident)) is None: raise _error("record", "correction id must match . with n from 1") unknown = sorted(set(record) - _LINE_FIELDS) if unknown: @@ -112,7 +119,7 @@ def allocate(entries: list[dict[str, Any]], target: str) -> str: highest = 0 for entry in entries: if entry.get("kind") == KIND and entry.get("corrects") == target: - highest = max(highest, split_id(entry["id"])[1]) + highest = max(highest, parts_of(entry["id"])[1]) return f"{target}.{highest + 1}" diff --git a/docket/ledger.py b/docket/ledger.py index eb9cb8f..6b12aae 100644 --- a/docket/ledger.py +++ b/docket/ledger.py @@ -76,9 +76,7 @@ def _normalized(value: str) -> str: return " ".join(value.split()).casefold() -def _reject_empty_reasoning( - record: dict[str, Any], changed: frozenset[str] | None = None -) -> None: +def _reject_empty_reasoning(record: dict[str, Any], changed: frozenset[str] | None = None) -> None: """Refuse a decision whose text or reasoning fields only echo the choice. Requiring the choice to appear in ``alternatives`` made a one-element list @@ -274,7 +272,7 @@ def __init__(self, entries: list[dict[str, Any]]) -> None: def add(self, entry: dict[str, Any]) -> None: # A correction is no relation target and carries no supersedes. if entry.get("kind") == corrections.KIND: - target, number = corrections.split_id(entry["id"]) + target, number = corrections.parts_of(entry["id"]) self.corrections[target] = max(self.corrections.get(target, 0), number) return ident = entry["id"] diff --git a/scripts/declarative_ledger.py b/scripts/declarative_ledger.py index 26ac761..6fd611e 100644 --- a/scripts/declarative_ledger.py +++ b/scripts/declarative_ledger.py @@ -156,8 +156,11 @@ def main(argv: list[str] | None = None) -> int: out.append(json.dumps(entry, ensure_ascii=False, separators=(",", ":"))) missed = sorted( - (entry["id"] for entry in map(json.loads, out) - if entry["kind"] == "decision" and entry["text"].rstrip().endswith("?")), + ( + entry["id"] + for entry in map(json.loads, out) + if entry["kind"] == "decision" and entry["text"].rstrip().endswith("?") + ), key=lambda ident: int(ident[1:]), ) if missed: diff --git a/tests/test_correct_cli.py b/tests/test_correct_cli.py index 4b46f3b..3095e4a 100644 --- a/tests/test_correct_cli.py +++ b/tests/test_correct_cli.py @@ -28,8 +28,15 @@ class CorrectCommandTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.cwd = self.tmp.name - out = run(self.cwd, "decision", "The cache lives in Redis.", "--choice", "Redis", - "--scope", "lib/cache.py") + out = run( + self.cwd, + "decision", + "The cache lives in Redis.", + "--choice", + "Redis", + "--scope", + "lib/cache.py", + ) self.assertEqual(out.returncode, 0, out.stderr) def tearDown(self): @@ -181,9 +188,20 @@ def setUp(self): self.cwd = self.tmp.name subprocess.run(["git", "init", "-q"], cwd=self.cwd, check=True) subprocess.run( - ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", - "--allow-empty", "-m", "init"], - cwd=self.cwd, check=True, + [ + "git", + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "--allow-empty", + "-m", + "init", + ], + cwd=self.cwd, + check=True, ) run(self.cwd, "decision", "The cache lives in Redis.", "--choice", "Redis") run(self.cwd, "correct", "d1", "--scope", "a.py") @@ -200,9 +218,23 @@ def test_check_accepts_a_correction(self): def test_check_reports_a_malformed_correction(self): with self.path.open("a") as stream: - stream.write(json.dumps({"schema": 2, "kind": "correction", "id": "d1.2", - "corrects": "d1", "fields": {"choice": "x"}, "reason": "", - "ts": "", "author": "", "session": "", "branch": ""}) + "\n") + stream.write( + json.dumps( + { + "schema": 2, + "kind": "correction", + "id": "d1.2", + "corrects": "d1", + "fields": {"choice": "x"}, + "reason": "", + "ts": "", + "author": "", + "session": "", + "branch": "", + } + ) + + "\n" + ) out = run(self.cwd, "check") self.assertEqual(out.returncode, 1) self.assertIn("line 3", out.stdout) diff --git a/tests/test_corrections.py b/tests/test_corrections.py index 2db3467..588f4a2 100644 --- a/tests/test_corrections.py +++ b/tests/test_corrections.py @@ -47,7 +47,9 @@ def test_the_target_must_be_earlier(self): def test_the_id_base_must_equal_corrects(self): with self.assertRaisesRegex(ledger.LedgerError, "must start with"): - ledger.validate_entries([decision("d1"), decision("d2"), line("d1.1", "d2", {"scope": []})]) + ledger.validate_entries( + [decision("d1"), decision("d2"), line("d1.1", "d2", {"scope": []})] + ) def test_a_malformed_id_is_refused(self): with self.assertRaisesRegex(ledger.LedgerError, "."): @@ -89,9 +91,7 @@ def test_empty_fields_are_refused(self): def test_unknown_line_fields_are_refused(self): with self.assertRaisesRegex(ledger.LedgerError, "unknown field"): - ledger.validate_entries( - [decision("d1"), line("d1.1", "d1", {"scope": []}, note="x")] - ) + ledger.validate_entries([decision("d1"), line("d1.1", "d1", {"scope": []}, note="x")]) def test_n_must_increase_per_target_and_gaps_are_allowed(self): ledger.validate_entries( @@ -105,7 +105,11 @@ def test_n_must_increase_per_target_and_gaps_are_allowed(self): ) with self.assertRaisesRegex(ledger.LedgerError, "must increase"): ledger.validate_entries( - [decision("d1"), line("d1.2", "d1", {"scope": []}), line("d1.1", "d1", {"scope": []})] + [ + decision("d1"), + line("d1.2", "d1", {"scope": []}), + line("d1.1", "d1", {"scope": []}), + ] ) def test_a_retired_record_is_correctable(self): @@ -165,7 +169,9 @@ def test_corrections_apply_in_file_order_and_the_lines_disappear(self): self.assertEqual(record["original"], {"scope": ["lib/a.py"], "rationale": ""}) def test_an_empty_list_clears_a_field(self): - projected = ledger.project([decision("d1", scope=["a.py"]), line("d1.1", "d1", {"scope": []})]) + projected = ledger.project( + [decision("d1", scope=["a.py"]), line("d1.1", "d1", {"scope": []})] + ) self.assertEqual(projected[0]["scope"], []) def test_an_uncorrected_record_gains_no_keys(self): diff --git a/tests/test_feature_brief.py b/tests/test_feature_brief.py index 4f89f41..5c2e236 100644 --- a/tests/test_feature_brief.py +++ b/tests/test_feature_brief.py @@ -104,13 +104,19 @@ def test_a_scope_correction_attaches_a_record(self): from docket import corrections, ledger record = ledger.make_record( - "decision", "The cache lives in Redis.", choice="Redis", author="t", - record_id="d1", scope=["lib/cache.py"], + "decision", + "The cache lives in Redis.", + choice="Redis", + author="t", + record_id="d1", + scope=["lib/cache.py"], ) fix = corrections.make("d1", {"scope": ["docket/cache.py"]}, author="t") fix["id"] = "d1.1" entries = ledger.project([record, fix]) - self.assertEqual([item["id"] for item in brief.attach(entries, ["docket/cache.py"])], ["d1"]) + self.assertEqual( + [item["id"] for item in brief.attach(entries, ["docket/cache.py"])], ["d1"] + ) self.assertEqual(brief.attach(entries, ["lib/cache.py"]), [])