Sync, phase 0: groundwork Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Status: planned (2026-11-03).
Goal: Both stores keep a per-machine journal of changes that rebuilds ledger.json and the
knowledge base exactly, with ids two Macs can never mint alike, so phase 1 only has to move
journal files.
Architecture: A pure change-log module (envelope, canonical bytes, ids, hybrid clock, order)
and a journal module (files, sequence numbers, transactions) are shared by both stores. The ledger
turns each write into changes by comparing the document before and after at locked_ledger, the
one place every write passes; the knowledge base journals the records it already appends. Pure
rebuild modules turn a journal back into the files, checking the rules a merge cannot keep and
returning conflict records. The journal is the commit point: the files record the journal position
they reflect and are rebuilt when it differs.
Tech Stack: bash and stdlib-only Python 3.13 (hashlib, hmac, json, secrets, fcntl);
unittest-style tests run by pytest; the smoke suite.
Spec: docs/design/sync.md (decisions 1, 3 and 7; Shape; Phases, row 0).
Global Constraints¶
- No third-party runtime dependency; stdlib Python 3.13 only. Shell stays bash 3.2 compatible.
- The checks in
CLAUDE.mdpass: ruff (McCabe <= 10), mypy --strict, complexipy (<= 15), lizard (cyclomatic <= 10, <= 60 lines), shellcheck, typos, links,mkdocs build --strict. Split a function rather than raise a limit. - Fictional data only in tests and fixtures (acme, placeholders); no real names or ids.
- Canonical bytes:
json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False); a record holding a float is refused. - Clock text:
f"{ms:013d}.{counter:04d}"; changes sort by(hlc, replica, id). - Id suffix: six lowercase Crockford base32 characters (
0123456789abcdefghjkmnpqrstvwxyz). - Unknown field paths are class
local(fail closed). - New files in the state dir get rows in
STATE_FILES(lib/uninstall_plan.py); the smoke suite fails otherwise. - Every doc a task affects changes in the same task (README,
docs/,CLAUDE.md). - Existing ids (
task:2026-10-15/1,f12) stay valid; new ones use the new form. - No config keys in this phase: nothing here is optional.
- Never run
install.sh,bosun init --recommended,get.shorbosun uninstallon this Mac.
Review Focus¶
- The first write on an existing ledger (old numeric ids, trimmed histories, pruned items,
approvals with quotes,
said): the genesis snapshot must rebuild it byte for byte. Pinned in Task 5 over the acme fixture. - A crash between the journal append and the file rewrite: the next write must rebuild, with the change present once. Pinned in Task 5.
- Twenty concurrent writers (the smoke suite's existing case): contiguous sequence numbers, no
lost or doubled change,
ledger validate --journalclean. Pinned in Task 5. - The wall clock going backwards (NTP, a manual change): the hybrid clock still increases. Pinned in Task 1.
- A command that edits a history entry in place:
ledger validate --journalmust report the difference, never pass silently. Pinned in Task 6.
File structure¶
| File | Responsibility |
|---|---|
lib/change_log.py (new, pure) |
Envelope, canonical bytes, change ids, hybrid clock, order, id suffixes |
lib/journal.py (new) |
Replica identity; a stream's journal files; transactions under the caller's lock |
lib/ledger_changes.py (new, pure) |
The field table, diff, rebuild with conflicts, history trimming (moved from ledger.py) |
lib/knowledge_changes.py (new, pure) |
Knowledge edits from a KB, and the knowledge rebuild |
lib/ledger.py |
locked_ledger journals; ids minted; ledger validate |
lib/knowledge.py, lib/knowledge_model.py, lib/knowledge_store.py |
Journal on save; minted fact ids; prev on updates; keyed forgotten hash; kg validate |
lib/learn.py |
The change lives on the learn: item; ids from the finding |
lib/console_kb.py |
Reads through knowledge.load |
lib/uninstall_plan.py |
journal, replica.json rows |
tests/unit/test_change_log.py, test_journal.py, test_ledger_changes.py, test_knowledge_changes.py (new) |
Their modules |
Part A (Tasks 1-2) is shared. Part B (Tasks 3-7) is the ledger and can ship as one pull request. Part C (Tasks 8-11) is the knowledge base and can ship as a second.
Part A: the change log¶
Task 1: Change log primitives¶
Files:
- Create:
lib/change_log.py - Test:
tests/unit/test_change_log.py
(mypy and the complexity hooks already cover all of lib/ and tests/unit/.)
Interfaces:
- Produces:
ENVELOPE_VERSION = 1;Change = dict[str, Any];class ChangeLogError(Exception)@dataclass(frozen=True) class Edit(op: str, target: str, body: dict[str, Any], local: bool): a change before the journal gives it an envelope@dataclass(frozen=True) class Conflict(change: str, target: str, kind: str, detail: str): a change the rebuild kept but did not apply;kindone of"state","link","field","deleted"canonical(record: Mapping[str, Any]) -> byteschange_id(record: Mapping[str, Any]) -> str(SHA-256 hex ofcanonicalof the record without itsid)tick(last: str, now_ms: int) -> strobserve(last: str, seen: str, now_ms: int, max_skew_ms: int) -> tuple[str, bool](the new clock, and whetherseenwas too far ahead to adopt)order_key(change: Mapping[str, Any]) -> tuple[str, str, str]in_order(changes: Iterable[Change]) -> list[Change](deduplicated byid, sorted)mint_suffix(replica: str, seed: str, n: int = 0) -> str
- [ ] Step 1: Write the failing tests in
tests/unit/test_change_log.py:test_canonical_is_sorted_and_compact:canonical({"b": 1, "a": "é"}) == '{"a":"é","b":1}'.encode()test_canonical_refuses_floats:canonical({"x": 1.5})raisesChangeLogError; so does a nested floattest_change_id_ignores_its_own_id:change_id({"a": 1}) == change_id({"a": 1, "id": "x"})and is 64 hex characterstest_tick_advances_with_wall_time:tick("0000000001000.0003", 2000) == "0000000002000.0000"test_tick_survives_a_clock_going_back:tick("0000000005000.0001", 4000) == "0000000005000.0002"test_observe_moves_past_what_it_saw:observe("0000000001000.0000", "0000000003000.0004", 2000, 600_000) == ("0000000003000.0005", False)test_observe_flags_a_far_future_clock: aseen11 minutes ahead withmax_skew_ms=600_000returns(tick(last, now_ms), True)test_in_order_sorts_and_deduplicates: three changes given shuffled with one duplicated come back once each, by(hlc, replica, id)test_mint_suffix_is_stable_and_short: same arguments give the same six characters from the Crockford alphabet; changing any argument changes it
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_change_log.py -qExpected: errors importingchange_log.
-
[ ] Step 3: Implement
lib/change_log.py. The clock follows Kulkarni et al.'s hybrid logical clock; a counter past 9999 moves to the next millisecond.observe:def observe(last: str, seen: str, now_ms: int, max_skew_ms: int) -> tuple[str, bool]: seen_ms, seen_counter = parse_clock(seen) if seen_ms - now_ms > max_skew_ms: return tick(last, now_ms), True last_ms, last_counter = parse_clock(last) top = max(last_ms, seen_ms, now_ms) if top == last_ms == seen_ms: counter = max(last_counter, seen_counter) + 1 elif top == last_ms: counter = last_counter + 1 elif top == seen_ms: counter = seen_counter + 1 else: counter = 0 return clock_text(top, counter), Falsemint_suffix: the first 30 bits ofsha256(f"{replica}|{seed}|{n}"), as six base32 characters.
-
[ ] Step 4: Run the tests to see them pass
Run:
uv run pytest tests/unit/test_change_log.py -qExpected: all pass.
-
[ ] Step 5: Commit
Task 2: Journals and transactions¶
Files:
- Create:
lib/journal.py - Test:
tests/unit/test_journal.py - Modify:
lib/uninstall_plan.py(("journal", LEDGER),("replica.json", LEDGER)inSTATE_FILES),tests/unit/test_uninstall_plan.pyif it counts rows
Interfaces:
- Consumes: Task 1's
canonical,change_id,tick,in_order,mint_suffix,ENVELOPE_VERSION. -
Produces:
REPLICA_FILE = "replica.json",LOCAL = "local",class JournalError(Exception)replica_id(state_dir: Path) -> str: 32 hex characters, created once withsecrets@dataclass class Journal(root: Path, stream: str, replica: str, rules: int, now_ms: Callable[[], int] = wall_ms)with:changes() -> list[Change]: every*.jsonlunderroot, in orderposition() -> dict[str, int]: the highestseqper file stem (replica ids and"local")begin(cmd: str) -> Transaction
class Transactionwith:add(op: str, target: str, body: dict[str, Any], *, local: bool = False) -> Nonemint() -> str:mint_suffix(replica, f"{private_next}/{local_next}", n),ncounting mints in this transactioncommit() -> list[Change]: nothing when empty; else onetickfor the transaction, then each change getsv,stream,replica,seq(its file's next),hlc,tx(the transaction's clock),cmd,rules,op,target,body,local(only when true), andidlast; appended as canonical lines with oneos.writeandfsyncper file
Files:
<root>/<replica>.jsonl(private changes) and<root>/local.jsonl(each with its own sequence). The caller holds the store's lock;Journaltakes none.
- [ ] Step 1: Write the failing tests in
tests/unit/test_journal.py:test_replica_id_is_made_once: two calls on one dir return the same 32-hex id; the file is in the dirtest_commit_numbers_each_file_from_one: a transaction with two private changes and one local change givesseq1, 2 in<replica>.jsonland 1 inlocal.jsonl;position()is{replica: 2, "local": 1}test_an_empty_transaction_writes_nothing: no file is createdtest_every_change_carries_the_envelope_and_a_valid_id: each line'sid == change_id(line), andv,stream,rules,cmd,txare as giventest_changes_come_back_in_order_across_files: two journals (two replicas) under one root read back by(hlc, replica, id)test_mint_is_unique_and_repeatable: two mints in one transaction differ; a second journal in a copy of the same dir mints the same first suffixtest_a_torn_last_line_is_reported: a file ending in half a JSON line raisesJournalErrornaming the file and line
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_journal.py -qExpected: errors importingjournal.
- [ ] Step 3: Implement
lib/journal.pyand the twoSTATE_FILESrows.
-
[ ] Step 4: Run the tests, and the uninstall tests
Run:
uv run pytest tests/unit/test_journal.py tests/unit/test_uninstall_plan.py -qExpected: all pass.
-
[ ] Step 5: Commit
Part B: the ledger¶
Task 3: The field table and diff¶
Files:
- Create:
lib/ledger_changes.py - Test:
tests/unit/test_ledger_changes.py - Modify:
lib/ledger.py(moveMAX_HISTORYandtrim_historyintoledger_changes;ledgerimports them)
Interfaces:
-
Produces:
- Classes
LOCAL, SEALED, PRIVATE, TEAM = "local", "sealed", "private", "team" - Rules
SET, STATE, HISTORY, LIST, LINKS = "set", "state", "history", "list", "links" @dataclass(frozen=True) class FieldRule(pattern: tuple[str, ...], rule: str, klass: str, important: bool = False);*matches one path segmentFIELDS: tuple[FieldRule, ...]classify(path: tuple[str, ...]) -> FieldRule(first match; elseFieldRule(path, SET, LOCAL))diff(before: Ledger, after: Ledger) -> list[change_log.Edit]MAX_HISTORY,trim_history(item)(moved unchanged)
Ops and bodies (paths relative to the item, as lists):
op target body item.createitem id {"item": <item without its local leaves>}item.deleteitem id {}item.setitem id {"path": [...], "value": v, "prev": p}(a missing leaf isnull)item.stateitem id {"from": s, "to": t}item.appenditem id {"path": [...], "entry": e}item.dropitem id {"path": [...], "entry": e}(only forLISTrules)item.link/item.unlinkitem id {"rel", "id", "at", "via"?}map.put/map.deletesourcesorruns{"key", "value"?, "prev"?}said.append/said.dropsaid{"entry": e}(always local)The table, in this order:
Pattern Rule Class Important items.*.stateSTATEprivate yes items.*.historyHISTORYsealed items.*.linksLINKSprivate items.*.title,items.*.fields.subject,items.*.report.summarySETsealed items.*.fields.messagesLISTsealed items.*.fields.requester,items.*.fields.to,items.*.fields.answer,items.*.fields.detailsSETsealed items.*.proposal.proposed_at,items.*.proposal.approval.used_atSETprivate yes items.*.proposal.approval.quote,.session,.tool_use_id,.transcriptSETlocal items.*.proposal,items.*.proposal.approval,items.*.delegation,items.*.report(the whole value, when it appears or goes)SETprivate items.*.proposal.*,items.*.proposal.approval.*SETprivate items.*.fields.key,items.*.fields.systemSETprivate yes items.*.kind,items.*.url,items.*.created_at,items.*.updated_at,items.*.fields.*,items.*.delegation.*,items.*.report.*SETprivate sources.*,runs.*SETprivate saidLISTlocal Comparison rules: dicts are compared leaf by leaf; a dict that appears, disappears or turns into a non-dict is one
item.setof the whole value at that path; lists follow their rule, and a list with no rule is oneitem.set.HISTORY: entries inafternot inbefore(compared as canonical bytes) are appended in order; entries that disappeared, andhistory-trimmedmarkers, are ignored.LINKS: by(rel, id, at). A leaf whose class islocalgoes in its ownitem.setwithlocal=True, never in a non-local body, including inside a whole value set at a container path. The top-leveljournalkey (Task 5) is not diffed. History entries aresealedwhole for now; phase 1's gate may strip only their notes. - Classes
- [ ] Step 1: Write the failing tests in
tests/unit/test_ledger_changes.py:test_no_change_no_edits:diff(x, deepcopy(x)) == []test_a_new_item_is_created_without_its_local_leavestest_a_field_change_records_its_prev:fields.repo"a"to"b"givesEdit("item.set", id, {"path": ["fields", "repo"], "value": "b", "prev": "a"}, False)test_a_state_change_records_from_and_totest_history_appends_and_ignores_trimming: an item trimmed bytrim_historyplus one new entry gives exactly oneitem.appendtest_links_and_unlinks_are_their_own_editstest_an_approval_quote_stays_local: approving a proposal gives a privateproposal.approval.used_at-free set and a local edit forquotetest_prune_is_a_delete;test_said_is_local;test_sources_and_runs_are_map_editstest_unknown_field_is_local: a newitems.*.mysteryleaf gives a local edittest_every_fixture_field_is_classified: every leaf path oftests/fixtures/console/acme-ledger.jsonmatches aFIELDSrow, not the fallback
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_ledger_changes.py -qExpected: errors importingledger_changes.
- [ ] Step 3: Implement
FIELDS,classifyanddiff; moveMAX_HISTORYandtrim_history.
-
[ ] Step 4: Run the tests and the ledger's existing tests
Run:
uv run pytest tests/unit/test_ledger_changes.py tests/unit/test_ledger.py tests/unit/test_ledger_protocol.py -qExpected: all pass.
-
[ ] Step 5: Commit
Task 4: Rebuilding the ledger, with conflicts¶
Files:
- Modify:
lib/ledger_changes.py,lib/ledger.py(aRULESvalue built fromKINDSandlineage) - Test:
tests/unit/test_ledger_changes.py
Interfaces:
- Consumes: Task 1's
in_orderandConflict; Task 3's ops andtrim_history. -
Produces:
@dataclass(frozen=True) class Rules(allowed: Callable[[str, str, str], bool], link_problem: Callable[[Items, Link], str | None])@dataclass class Rebuilt(ledger: Ledger, conflicts: list[change_log.Conflict])rebuild(changes: Iterable[Change], rules: Rules) -> Rebuiltstate_hash(ledger: Ledger) -> str(SHA-256 ofcanonicalof the ledger withoutjournal)- In
lib/ledger.py:RULES = ledger_changes.Rules(allowed=..., link_problem=...), whereallowed(kind, from, to)isto in allowed_targets(kind, from)andlink_problemwrapslineage.check_link(itsLineageErrormessage, orNone)
Rebuild rules: apply
in_order(changes)to an empty ledger ({"version": 1, "items": {}, "sources": {}, "runs": {}, "said": []}), thentrim_historyevery item.item.stateapplies whenfromequals the current state andrules.allowedholds, else astateconflict.item.seton an important path applies whenprevequals the current value, else afieldconflict; other sets always apply (last in order wins).item.linkapplies unlessrules.link_problemreturns a reason (alinkconflict);item.unlinkremoves the link with that(rel, id, at)only. Any change to a deleted item, other than another delete, is adeletedconflict.item.createof an existing id merges: fields the item lacks are added, histories are unioned in order.
- [ ] Step 1: Write the failing tests:
test_rebuild_of_diffs_reproduces_the_ledger: run everycmd_*the protocol tests use on a scratch ledger, diff each write, rebuild: equal to the finalledger.json(withoutjournal)test_order_of_delivery_does_not_matter: the same changes shuffled and duplicated give the samestate_hashtest_two_transitions_from_one_state: replicas A and B movereview:acme/api#7fromrequestedtodelegatedand toclosed; one applies, the other is astateconflict, the same on either delivery ordertest_a_cycle_made_on_two_machines_is_refused: A links xspawned_fromy, B links yspawned_fromx; the later one is alinkconflicttest_an_approval_used_twice: twoused_atsets withprevnull; the second is afieldconflicttest_delete_wins: a transition after a delete is adeletedconflict and the item stays gonetest_a_concurrent_unlink_keeps_a_re_add: an unlink of(rel, id, at1)leaves a link added withat2
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_ledger_changes.py -qExpected: the new tests fail.
- [ ] Step 3: Implement
rebuild,state_hashandledger.RULES.
-
[ ] Step 4: Run the tests
Run:
uv run pytest tests/unit/test_ledger_changes.py -qExpected: all pass.
-
[ ] Step 5: Commit
Task 5: Every ledger write goes through the journal¶
Files:
- Modify:
lib/ledger.py(Store,locked_ledger,main),lib/guard.py,lib/hook.py,lib/review_watch.py(their command names),tests/smoke.sh,docs/setup.md(the state folder list),CLAUDE.md(where things live) - Test:
tests/unit/test_ledger_protocol.py,tests/unit/test_corpus.py
Interfaces:
- Consumes: Task 2's
Journal,replica_id; Task 3'sdiff; Task 4'srebuild,RULES. -
Produces:
Store.journal_dir(state_dir / "journal" / "ledger"),Store.command: str(set bymaintof"ledger {args.command}"; by the guard, the hook and review-watch to their names)ledger.jsongains the top-level key"journal": theposition()it reflectsRULES_VERSION = 1inlib/ledger.py, passed as the journal'srulescurrent_transaction() -> journal.Transaction(valid insidelocked_ledger(write=True))
locked_ledger(write), under the existing lock:- Open the journal. If it is empty and
ledger.jsonhas items: commit a genesis transaction (cmd"genesis") with anitem.createper item (history included), amap.putper source and run, and a localsaid.appendper entry. - If
ledger.json's"journal"differs fromposition(): rebuild, save, and use the rebuilt ledger. - Copy the ledger, begin a transaction, yield.
- On write: add
diff(copy, ledger), commit, setledger["journal"] = position(),save(as today, even with no edits).
- [ ] Step 1: Write the failing tests in
tests/unit/test_ledger_protocol.py:test_the_journal_rebuilds_the_ledger_after_every_command: after the setUp commands and a delegation, report and transition,rebuild(journal.changes(), RULES).ledgerequalsledger.jsonwithoutjournaltest_genesis_reproduces_an_existing_ledger: copy the acme fixture in asledger.json, runledger list, thenledger render; the rebuild equals the fixturetest_a_crash_after_the_journal_is_repaired: commit a transaction by hand without saving; the next command's ledger has that change once, and"journal"equalsposition()test_concurrent_writers_keep_contiguous_sequences: the existing 20-writer test also checksseqruns 1..n in<replica>.jsonlwith no gaps and the rebuild equals the file- In
tests/unit/test_corpus.py: after each replay scenario, the rebuild equalsledger.json
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_ledger_protocol.py tests/unit/test_corpus.py -qExpected: the new tests fail.
- [ ] Step 3: Implement it in
locked_ledger. Commands, readers andrenderdo not change.
- [ ] Step 4: Measure a synthetic year. A throwaway script (not committed) writes 100 items
with 500 changes each through
locked_ledgerand timesrebuild. Record the time in the open question "Rebuild cost" indocs/design/sync.md. If it exceeds one second, note snapshots as needed in phase 1.
- [ ] Step 5: Add smoke checks: after the smoke suite's ledger section, the journal exists
under
$BOSUN_STATE_DIR/journal/ledger/andbosun ledger validate --journal(Task 6) prints nothing wrong. Updatedocs/setup.md(journal/,replica.jsonin the state folder list) andCLAUDE.md(the change log, journal and ledger-changes modules under "Where things live").
-
[ ] Step 6: Run everything
Run:
uv run pytest tests/unit -q && tests/smoke.shExpected: all pass.
-
[ ] Step 7: Commit
Task 6: bosun ledger validate¶
Files:
- Modify:
lib/ledger.py(cmd_validate, its line in the module's usage),docs/orchestrator.md(next toledger lint) - Test:
tests/unit/test_ledger.py,tests/smoke.sh
Interfaces:
- Consumes: Task 4's
rebuild,RULES; Task 5's journal. -
Produces:
cmd_validate(args) -> tuple[str, int]; options--journal,--json.Without
--journalit checks the current ledger against every rule: a state that is not one of its kind's states, a linklineage.check_linkwould refuse, more than onepart_of, a link to an unknown item. With--journalit also rebuilds and reports each conflict and each item where the rebuild andledger.jsondiffer (the first differing path). Exit 0 when clean, 1 otherwise.
- [ ] Step 1: Write the failing tests:
test_validate_passes_a_clean_ledgertest_validate_finds_a_bad_state: set an item's state by hand to"nonsense"; exit 1, the item namedtest_validate_journal_finds_an_in_place_edit: insidelocked_ledger(write=True), edit the newest history entry's note in place;validate --journalexits 1 and names the item andhistorytest_validate_journal_lists_conflicts: two hand-made conflicting transitions in the journal are listed
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_ledger.py -k validate -qExpected: fail (novalidatecommand).
- [ ] Step 3: Implement
cmd_validate, add it toCOMMANDSand to the usage text; one line indocs/orchestrator.mdbesideledger lint.
-
[ ] Step 4: Run the tests and the smoke suite
Run:
uv run pytest tests/unit/test_ledger.py -q && tests/smoke.shExpected: all pass.
-
[ ] Step 5: Commit
Task 7: Ids two Macs never mint alike¶
Files:
- Modify:
lib/ledger.py(next_task_id,next_outreach_id,TASK_ID,OUTREACH_ID, theid_formatof task, outreach and learning kinds),lib/learn.py(next_id,record_proposal,pending_change,sync_statuses,State),tests/fixtures/console/make-acme-ledger.shand the fixture, the tests that name task or outreach ids,docs/orchestrator.md,docs/learning.md - Test:
tests/unit/test_ledger.py,tests/unit/test_outreach.py,tests/unit/test_learn.py
Interfaces:
- Consumes: Task 1's
mint_suffix; Task 5'scurrent_transaction(); Task 2'sreplica_id. - Produces:
next_task_id(items)returnsf"task:{utc_date}/{current_transaction().mint()}"next_outreach_id(ledger, scope)returnsf"out:{scope}/{current_transaction().mint()}", with the scope's date (no parent) in UTCTASK_ID = re.compile(r"^task:\d{4}-\d{2}-\d{2}/([1-9]\d*|[0-9a-hjkmnp-tv-z]{6})$"), and the same alternative inOUTREACH_IDlearn.next_id(today, key, run, replica)returnsf"learn:{today:%Y-%m-%d}/{mint_suffix(replica, f'{key}/{run}')}"- The
learn:item carries the change: fieldskey,target,run(as today) plustextandwindow;pending_changebuilds the change from the item, resolvingdirfrom the window withtarget_dir;State.proposalsgoes.State.loadmoves a legacyproposalsmap onto its items once (--field text=... --field window=...) and drops it.
- [ ] Step 1: Write the failing tests:
test_spawn_mints_a_hash_id:spawnprints an id matching^task:\d{4}-\d{2}-\d{2}/[0-9a-hjkmnp-tv-z]{6}$test_old_task_ids_still_parse:upsert task:2026-10-15/1 --kind taskis acceptedtest_two_replicas_mint_different_ids: two state dirs with differentreplica.jsonspawn the same task first; the ids differtest_a_pruned_id_is_never_minted_again: spawn, finish, prune, spawn: a different idtest_reach_out_mints_a_hash_id(outreach, with and without a parent)test_learn_id_comes_from_the_finding: same key and run give the same id; another run gives anothertest_learn_apply_reads_the_change_from_the_item:state.jsonwithoutproposals;applywrites the memory note into the window's foldertest_legacy_learn_proposals_move_onto_items
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_ledger.py tests/unit/test_outreach.py tests/unit/test_learn.py -qExpected: the new tests fail.
- [ ] Step 3: Implement the ids and the learn change on the item. Update
make-acme-ledger.shto write a fixedreplica.jsoninto its scratch state dir and to take task and outreach ids from the commands' output; rebuild the fixture with it; update the tests that name its ids. Update the id lines indocs/orchestrator.md(task:<YYYY-MM-DD>/<id>,out:<parent-id>/<id>) anddocs/learning.md.
-
[ ] Step 4: Run everything
Run:
uv run pytest tests/unit -q && tests/js_check.sh && tests/smoke.shExpected: all pass.
-
[ ] Step 5: Commit
Part C: the knowledge base¶
Task 8: A keyed hash for forgotten people¶
Files:
- Modify:
lib/knowledge_model.py(hash_id,forgotten_hashes,check_new_entity,forget),lib/knowledge.py(load the key),lib/knowledge_store.py(Paths.key_file,GIT_IGNORE),docs/design/knowledge.md(Privacy) - Test:
tests/unit/test_knowledge.py
Interfaces:
- Produces:
Paths.key_file=root / "secret.key": 32 random bytes as hex, mode 0600, created on the first write, git-ignoredKB.secret: bytes(empty when there is no key yet)hash_id(entity_id: str, secret: bytes) -> str: HMAC-SHA256, first 16 hex characters- New forgotten records carry
"hash_kind": "hmac-sha256"; a record withouthash_kindis compared assha256(entity_id)[:16], as today
- [ ] Step 1: Write the failing tests:
test_forget_records_a_keyed_hash: the record's hash is notsha256("person/dana")[:16], andhash_kindis settest_a_forgotten_person_needs_allow_forgotten_again(new-style record)test_an_old_forgotten_record_still_blocks_re_adding(record withouthash_kind)test_the_key_is_private_and_not_in_git: mode 0600; absent fromgit ls-files
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_knowledge.py -k forget -q
- [ ] Step 3: Implement it; one sentence in the design note's Privacy section.
-
[ ] Step 4: Run the tests
Run:
uv run pytest tests/unit/test_knowledge.py -qExpected: all pass.
-
[ ] Step 5: Commit
Task 9: The knowledge journal, minted fact ids, prev on updates¶
Files:
- Create:
lib/knowledge_changes.py,tests/unit/test_knowledge_changes.py - Modify:
lib/knowledge.py(load,save,opened,Settings),lib/knowledge_model.py(KB.mint,next_fact_id, oneupdate_recordbuilder used by every update),docs/knowledge.md(fact ids)
Interfaces:
- Consumes: Task 1's
Edit; Task 2'sJournal,replica_id. -
Produces:
KG_RULES_VERSION = 1inlib/knowledge.py; the journal atroot / "journal", stream"kg", replica fromreplica_id(Path(SETTINGS.state_dir))KB.mint: Callable[[], str];next_fact_id(kb)returnsf"f-{kb.mint()}"update_record(kb, fact, event, changes, **extra) -> Record: today's update record plus"prev": {k: fact.get(k) for k in changes}knowledge_changes.edits(before_entities, kb) -> list[Edit]:fact.addandfact.update(body: the record) forkb.new_records;entity.put(body: the entity) andentity.deletefrom comparing entities; local when the fact's subject or object, or the entity, is aperson/idview.jsoninroot:{"journal": <position>}, written after the files;savejournals first
A rewrite (
kg forget, the retention purge) only removes person records, which are local:saverewriteslocal.jsonlwithout them and refuses a rewrite that would remove a private record. The genesis transaction journals the existing entities andfacts.jsonlrecords.
- [ ] Step 1: Write the failing tests:
test_a_new_fact_gets_a_minted_id:f-and six base32 characters; existingf12facts keep their idstest_updates_carry_what_they_replaced: confirming a proposed fact writesprev.status == "proposed"test_person_facts_go_to_the_local_journal;test_product_facts_go_to_the_replica_journaltest_forget_rewrites_only_the_local_journal: the person's records are gone fromlocal.jsonland fromgit log -p --allafter the history restart, as the existing test checks forfacts.jsonltest_genesis_journals_an_existing_knowledge_base: the acme import, then a write; the journal holds every entity and record once
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_knowledge_changes.py tests/unit/test_knowledge.py -q
- [ ] Step 3: Implement it. Update
docs/knowledge.mdwhere it shows fact ids.
-
[ ] Step 4: Run the tests
Run:
uv run pytest tests/unit -qExpected: all pass.
-
[ ] Step 5: Commit
Task 10: Rebuilding the knowledge base, and kg validate¶
Files:
- Modify:
lib/knowledge_changes.py,lib/knowledge.py(openedrebuilds whenview.jsondiffers from the journal position;cmd_validate),lib/knowledge_model.py(fold_alltakes records in the given order and holds updates for a fact not seen yet),docs/knowledge.md(kg validatenext tokg lint) - Test:
tests/unit/test_knowledge_changes.py,tests/smoke.sh
Interfaces:
- Consumes: Task 1's
in_order; Task 9's journal and edits. - Produces:
@dataclass class RebuiltKB(entities: dict[str, Record], records: list[Record], conflicts: list[change_log.Conflict], pending: list[str])rebuild(changes: Iterable[Change]) -> RebuiltKB: records in order; an update whose fact has not been added is held and applied when the add comes; one still held at the end is listed inpending; an update whoseprev.statusdiffers from the current status is afieldconflict and not appliedbosun kg validate [--json]: the rebuild against the files, conflicts and pending updates; exit 0 when clean
- [ ] Step 1: Write the failing tests:
test_rebuild_reproduces_the_files_after_every_kg_command: entities and the folded facts match the files afteradd,propose,confirm,reject,supersede,forget,expiretest_an_early_update_waits_for_its_fact: an update ordered before its add is applied after ittest_confirm_and_reject_at_once_is_a_conflict: two updates fromproposed, one toconfirmed, one torejected; the later is a conflicttest_order_of_delivery_does_not_matter(shuffled, duplicated)test_a_crash_before_the_files_is_repaired: a journaled transaction without the files; the next write rebuilds
-
[ ] Step 2: Run them to see them fail
Run:
uv run pytest tests/unit/test_knowledge_changes.py -q
- [ ] Step 3: Implement it; add
kg validateto the CLI, its usage,docs/knowledge.mdand a smoke check after the smoke suite's knowledge section.
-
[ ] Step 4: Run everything
Run:
uv run pytest tests/unit -q && tests/smoke.shExpected: all pass.
-
[ ] Step 5: Commit
Task 11: The console reads through knowledge.load, and phase 0 is done¶
Files:
- Modify:
lib/console_kb.py(read_kbcallsknowledge.loadwith identifiers left out),lib/knowledge.py(load(p, *, identifiers: bool = True)),docs/design/sync.md(status: phase 0 built; the rebuild cost measured in Task 5),ROADMAP.md(section 6: phase 0 done),CLAUDE.md(the knowledge-changes module) - Test:
tests/unit/test_console_knowledge.py,tests/unit/test_console_kg_views.py
Interfaces:
- Consumes: Task 9's
load. - Produces:
knowledge.load(p, *, identifiers=True); the console passesidentifiers=Falseand never readsidentifiers.jsonlorforgotten.jsonl, as today.
- [ ] Step 1: Write the failing test
test_console_reads_no_identifiers: with an unreadableidentifiers.jsonl(mode 000), the console's knowledge view still loads.
-
[ ] Step 2: Run it to see it fail
Run:
uv run pytest tests/unit/test_console_knowledge.py -k identifiers -q
- [ ] Step 3: Implement it; update the design note's status, the roadmap and
CLAUDE.md.
-
[ ] Step 4: Run every check
Run:
uv run pytest tests/unit -q && tests/js_check.sh && tests/smoke.sh && uv run pre-commit run --all-filesExpected: all pass.
-
[ ] Step 5: Commit