feat(vault): port index0/atlas, and keep it current on every mutation

index0 is the per-vault activity ledger under `_system.index0`: an append-only
event stream per writer, daily checkpoints rolling those events up, and
derived canonical views (children_of, counts_by_kind, recent_activity).

It was the last piece carried through verbatim rather than computed — which
was safe for round-tripping but meant a TypeScript client never UPDATED it. A
vault written by this port went stale the moment it was touched, and a Python
client reading it afterwards saw an activity log missing everything the TS
client had done. Worse, `nostr restore` kept the local block verbatim, so a
restore silently discarded the remote's history.

Hash parity is the entire contract here: event ids, integrity hashes,
checkpoint summaries and view hashes are SHA-256 over Python's canonical JSON,
and the merge picks winners by comparing those hashes — one byte of encoding
disagreement means the two implementations permanently disagree about which
record wins. Everything routes through the existing canonicalJson (Python's
ensure_ascii escaping, code-point key ordering, CPython number formatting)
rather than JSON.stringify, and the tests compare whole structures via
canonical JSON rather than toEqual, so they pass only if the bytes that get
hashed are identical. Verified against Python fixtures covering the append
chain, compaction, manifest metadata, views and a two-replica merge —
including non-ASCII labels and tags, a non-numeric entry id, two writers
across two days, and a managed-account scope path.

Two Python quirks are replicated deliberately rather than "fixed", because
diverging would change which events exist rather than merely how they hash:
`str(None)` is "None" (so a JSON null in a required field is a non-empty
string there), and a stored integrity_hash is preserved even when it
disagrees — this normalizes, it does not verify, and silently rewriting a
mismatched hash would hide the tampering the hash exists to reveal.

Events are emitted by DIFFING the index at the single mutation funnel each
surface goes through, rather than from each call site as Python does. That is
deliberate: a call-site emitter is something a new code path can forget, and a
silently incomplete ledger is worse than an absent one because it reads as
"nothing happened". The event vocabulary matches Python's, so a stream from
either implementation reads the same. Emission is best-effort — the atlas is
derived state, and it must never fail a committed vault write.

mergeIndexPayloads now defaults to merging index0 with the same deterministic
rules; "preserve-current" and "reject" remain for callers that want them, and
`nostr restore` no longer discards the remote's history.

Also made gen_high_risk deterministic. Fernet embeds a random IV and the
current time, so that fixture regenerated differently every run and would have
failed the fixture-drift CI job each time — it now pins both, with a note that
production must never do this.

Verified end to end: the TS binary performed four mutations, and Python read
the resulting atlas, normalized it without dropping a record, and compacted it
into checkpoints and views.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 11:13:15 -04:00
1 parent 64b27d7bf9
commit c6cb85e840
15 files changed
+3396 -31

No files matched your search

+125 -3
View File
@@ -1379,6 +1379,14 @@ def gen_high_risk() -> dict:
salt = bytes(range(16))
partition_key = base64.urlsafe_b64encode(bytes(range(32))).decode("ascii")
# Fernet embeds a random IV and the current time, so `encrypt` would emit
# different bytes on every run and the fixture-drift CI job would fail
# each time it regenerated. `_encrypt_from_parts` pins both. Production
# must never do this -- a repeated IV under one key breaks CBC -- which is
# why it appears only here, in a generator producing test vectors from a
# fixed key.
fixed_iv = bytes(range(16))
fixed_time = 1700000000
wrapping_key = iso._derive_wrapping_key(factor, salt, iso.PARTITION_KDF_ITERATIONS)
envelope = {
"version": iso.PARTITION_ENVELOPE_VERSION,
@@ -1386,7 +1394,7 @@ def gen_high_risk() -> dict:
"iterations": iso.PARTITION_KDF_ITERATIONS,
"salt_b64": base64.b64encode(salt).decode("ascii"),
"wrapped_partition_key": Fernet(wrapping_key)
.encrypt(partition_key.encode("utf-8"))
._encrypt_from_parts(partition_key.encode("utf-8"), fixed_time, fixed_iv)
.decode("utf-8"),
}
tag = iso._partition_key_tag(partition_key)
@@ -1402,11 +1410,28 @@ def gen_high_risk() -> dict:
},
}
# Same reason: build the partition blob with a pinned IV and time rather
# than calling save_partition_entries, whose Fernet output is random.
payload = json.dumps(
{
"schema_version": 1,
"partition": "high_risk",
"updated_at_utc": float(fixed_time),
"entries": entries,
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
file_key = hashlib.sha256(tag.encode("utf-8")).digest()
blob = Fernet(base64.urlsafe_b64encode(file_key))._encrypt_from_parts(
payload, fixed_time, fixed_iv
)
# Prove the pinned construction is what the real writer/reader accept.
with tempfile.TemporaryDirectory() as tmpdir:
profile = Path(tmpdir) / "PROFILE"
profile.mkdir()
store.save_partition_entries(profile, tag, entries)
blob = (profile / store.PARTITION_FILENAME).read_bytes()
(profile / store.PARTITION_FILENAME).write_bytes(blob)
assert store.load_partition_entries(profile, tag) == entries
return {
"description": "high-risk partition envelope and file, for cross-impl checks",
@@ -1419,6 +1444,102 @@ def gen_high_risk() -> dict:
}
def gen_index0() -> dict:
"""index0/atlas reference state for the TypeScript port.
Everything in index0 is hashed with SHA-256 over Python's canonical JSON,
and the merge picks winners by comparing those hashes -- so a single byte
of encoding disagreement makes the two implementations permanently
disagree about which record wins. These vectors are compared whole, via
canonical JSON, rather than field by field.
The inputs deliberately include non-ASCII labels and tags (ensure_ascii
escaping), a non-numeric entry id (view ordering must degrade, not throw),
two writers on two days (checkpoint grouping and retention), and a managed
account (the second scope path).
"""
from seedpass.core import index0 as index0_mod
fp_dir = "/home/user/.seedpass/C557EEC878DFD852"
managed_dir = "/home/user/.seedpass/C557EEC878DFD852/accounts/AABBCCDDEEFF0011"
entries = {
"0": {"kind": "password", "label": "bank.example", "modified_ts": 1700000100,
"tags": ["money", "daily"],
"links": [{"target_id": "2", "relation": "depends_on", "note": "recovery"}]},
"1": {"kind": "totp", "label": "email-2fa", "modified_ts": 1700000200,
"archived": True},
"2": {"kind": "ssh", "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713",
"modified_ts": 1700000300, "tags": ["ops", "\u00fcn\u00ef"]},
"10": {"kind": "document", "label": "notes", "modified_ts": 1700000400},
"not-a-number": {"kind": "password", "label": "weird id",
"modified_ts": 1700000500},
}
payload = {"schema_version": 4, "entries": entries}
specs = [
(1700000100, "entry.created", "0", "password"),
(1700000200, "entry.created", "1", "totp"),
(1700086400, "entry.updated", "0", "password"),
(1700086500, "entry.archived", "1", "totp"),
]
for i, (ts, event_type, subject_id, subject_kind) in enumerate(specs):
payload = index0_mod.append_index0_event(
payload,
event_type=event_type,
subject_type="entry",
subject_id=subject_id,
subject_kind=subject_kind,
modified_ts=ts,
fingerprint_dir=fp_dir,
tags=["auto"],
summary=f"event {i} \u2014 \u00fcn\u00ef",
)
payload = index0_mod.append_index0_event(
payload,
event_type="entry.created",
subject_type="entry",
subject_id="7",
subject_kind="seed",
modified_ts=1700090000,
fingerprint_dir=managed_dir,
)
compacted = index0_mod.compact_index0_payload(payload, fingerprint_dir=fp_dir)
other = index0_mod.append_index0_event(
{"schema_version": 4, "entries": entries},
event_type="entry.created",
subject_type="entry",
subject_id="99",
subject_kind="password",
modified_ts=1700099999,
fingerprint_dir=fp_dir,
)
scope = index0_mod.derive_index0_context(fp_dir)["scope_path"]
return {
"description": "index0/atlas events, checkpoints, views and merge",
"entries": entries,
"context_root": index0_mod.derive_index0_context(fp_dir),
"context_managed": index0_mod.derive_index0_context(managed_dir),
"appended": payload,
"compacted": compacted,
"manifest_meta": index0_mod.build_manifest_index0_metadata(
payload, fingerprint_dir=fp_dir
),
"other": other,
"merged": index0_mod.merge_system_index0(
compacted["_system"]["index0"], other["_system"]["index0"]
),
"views": index0_mod.list_canonical_views(compacted["_system"]["index0"]),
"one_view": index0_mod.get_canonical_view(
compacted["_system"]["index0"],
view_type="counts_by_kind",
scope_path=scope,
),
}
def main() -> None:
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
@@ -1445,6 +1566,7 @@ def main() -> None:
"qr.json": gen_qr(),
"semantic.json": gen_semantic(),
"high_risk.json": gen_high_risk(),
"index0.json": gen_index0(),
}
entries_fixture, vault_fixture = gen_entries_and_vault()
files["entries_index.json"] = entries_fixture