diff --git a/js/packages/cli/src/api/routes.ts b/js/packages/cli/src/api/routes.ts index 1c5514f8..8f955596 100644 --- a/js/packages/cli/src/api/routes.ts +++ b/js/packages/cli/src/api/routes.ts @@ -39,6 +39,7 @@ import { removeLink, restoreEntry, isPartitionStub, + emitEntryEvents, splitSecret, recoverSecret, buildSemanticRecords, @@ -183,7 +184,20 @@ async function mutate( const mnemonic = requireUnlocked(ctx); const result = await withVaultLock(path, async () => { const vault = await openVault(path, mnemonic); + // Snapshot before mutating so the index0 differ sees what changed. + const before = structuredClone(vault.index.entries) as Record; const out = await fn(vault.index); + try { + const updated = emitEntryEvents(vault.index, { + before, + after: vault.index.entries as unknown as Record, + fingerprintDir: profileDir(ctx), + now: Math.floor(ctx.now() / 1000), + }); + (vault.index as unknown as Record)["_system"] = updated["_system"]; + } catch { + // Derived state; never fail a committed write over it. + } await saveVaultHoldingLock(vault); return out; }); diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index 68761f40..fa61f3bb 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -136,6 +136,17 @@ export function capabilities(): Record { // letting the job run under rules nobody reviewed it against. policy_binding: "sha256 of the canonical agent_policy.json at creation", }, + index0: { + // The per-vault activity ledger under _system.index0: an append-only + // event stream per writer, daily checkpoints, and derived views. + events: "appended automatically on every vault mutation (CLI, TUI, API)", + event_types: ["entry_created", "entry_updated", "entry_deleted"], + canonical_views: ["children_of", "counts_by_kind", "recent_activity"], + // Never synced: derived from data that does not leave the machine. + local_only_views: ["conversation_index", "hot_nodes", "semantic_neighbors"], + merge: "deterministic; events union by id, the rest resolve by timestamp then hash", + interop: "hashes and merge results byte-identical to the Python implementation", + }, audit: { commands: ["agent audit-verify", "agent audit-tail"], chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX", @@ -150,9 +161,6 @@ export function capabilities(): Record { // reproducible, so a TS implementation would derive a DIFFERENT key // from the same seed. Refusing beats silently diverging. "pgp RSA keys (ed25519 is at byte parity; RSA generation is not reproducible)", - // Python-derived state, recomputed on load. Carried through verbatim by - // the TS port so a Python profile round-trips, but not computed here. - "index0/atlas (preserved verbatim on read/write, never recomputed)", // Deliberate: the TS interactive mode follows Python's legacy v1 menu // tree. See docs/tui_v2_cutover_decision.md. "Python's v2/v3 Textual TUIs (interactive mode follows the legacy v1 menus)", diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 34eb27b6..b80da904 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -30,6 +30,7 @@ import { splitSecret, recoverSecret, isPartitionStub, + emitEntryEvents, partitionStub, type Entry, buildSemanticRecords, @@ -324,7 +325,11 @@ async function mutateVault( const mnemonic = await resolveMnemonic(app, opts); return withVaultLock(path, async () => { const vault = await openVault(path, mnemonic); + // Snapshot before mutating so the index0 differ can see what changed. + // Structured-cloned, because `fn` mutates the index in place. + const before = structuredClone(vault.index.entries) as Record; const result = await fn(vault); + recordIndex0(vault, before, dirname(path)); await saveVaultHoldingLock(vault); // Rolling snapshot, after the write and still under the lock, matching // Python's create_backup() call order. Best-effort by design: a failed @@ -397,6 +402,31 @@ function resolveFromRows( throw new Error(`no entry matches "${refOrQuery}"`); } +/** + * Append index0 events for whatever a mutation changed. + * + * Best-effort: the atlas is derived state and can be rebuilt, so a failure + * here must never fail a committed vault write. Silence in the ledger is a + * lesser harm than refusing to save the user's entry. + */ +function recordIndex0( + vault: OpenedVault, + before: Record, + fingerprintDir: string, +): void { + try { + const updated = emitEntryEvents(vault.index, { + before, + after: vault.index.entries as unknown as Record, + fingerprintDir, + now: Math.floor(Date.now() / 1000), + }); + (vault.index as unknown as Record)["_system"] = updated["_system"]; + } catch { + // Deliberately swallowed; see the note above. + } +} + async function openReadAccess(opts: GlobalOpts): Promise { const token = process.env["SEEDPASS_TOKEN"]; const app = new AppDir(resolveAppDir(opts.appDir)); @@ -1429,13 +1459,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { await decryptPayload(indexKey, parseEncryptedFile(delta).ciphertext), ), ); - // preserve-current here too: the default would refuse any - // Python profile that carries atlas state as soon as one delta - // exists. // Same collector as the final merge: a delta can just as easily // carry an entry that displaces one the snapshot created. + // + // index0 now merges properly. It used to be preserve-current + // because the atlas could not be recomputed here, which meant a + // delta's activity history was discarded on the way in. remote = mergeIndexPayloads(remote, incoming, sha256Hex(delta).slice(0, 16), { - index0: "preserve-current", report, }); deltaCount++; @@ -1464,13 +1494,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { final = remote; mode = local === null ? "restored" : "replaced"; } else { - // Keep the local _system.index0 verbatim: it is Python-derived - // atlas state recomputed on load, and refusing to merge it would - // block every restore into a Python-created profile. - final = mergeIndexPayloads(local, remote, "nostr-restore", { - index0: "preserve-current", - report, - }); + // index0 merges both sides now. It used to keep the local block + // verbatim, because the atlas could not be merged here — which + // meant a restore silently discarded the remote's activity history. + final = mergeIndexPayloads(local, remote, "nostr-restore", { report }); mode = "merged"; } diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index ec684bd8..a7951df5 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -48,7 +48,7 @@ import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type Opene import { entryMetadata, refFor } from "../refs.js"; import { materializeSecret, type MaterializedSecret } from "../secrets.js"; import { clipboardSink } from "../sinks.js"; -import { encodeQr, renderQrText } from "@seedpass/core"; +import { encodeQr, renderQrText, emitEntryEvents } from "@seedpass/core"; import { createIndexBackup, type BackupResult } from "../backups.js"; import { loadConfig, @@ -168,7 +168,20 @@ async function mutate(s: Session, fn: (vault: OpenedVault) => void): Promise { const fresh = await openVault(path, mnemonic); + // Snapshot before mutating so the index0 differ sees what changed. + const before = structuredClone(fresh.index.entries) as Record; fn(fresh); + try { + const updated = emitEntryEvents(fresh.index, { + before, + after: fresh.index.entries as unknown as Record, + fingerprintDir: s.app.profileDir(s.fingerprint), + now: Math.floor(s.clock() / 1000), + }); + (fresh.index as unknown as Record)["_system"] = updated["_system"]; + } catch { + // Derived state; never fail a committed write over it. + } await saveVaultHoldingLock(fresh); s.vault = fresh; try { diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index d82a50f9..0371c3da 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -112,6 +112,42 @@ export { MAX_TOTAL_SHARES, type ParsedShare, } from "./vault/recoverySplit.js"; +export { + normalizeIndex0, + normalizeIndex0Event, + normalizeIndex0Checkpoint, + normalizeCanonicalView, + normalizeHead, + normalizeViewManifest, + ensureIndex0Payload, + deriveIndex0Context, + makeIndex0Event, + appendIndex0Event, + buildDailyCheckpoint, + rebuildIndex0Checkpoints, + compactIndex0, + compactIndex0Payload, + rebuildCanonicalViewsPayload, + buildManifestIndex0Metadata, + listCanonicalViews, + getCanonicalView, + mergeSystemIndex0, + recomputeIndex0Stats, + // Aliased: `computeEventId` is already taken by the Nostr event module, + // and the two hash entirely different things. + computeEventHash as computeIndex0EventHash, + computeEventId as computeIndex0EventId, + computeHeadHash as computeIndex0HeadHash, + computeCheckpointHash as computeIndex0CheckpointHash, + computeViewHash as computeIndex0ViewHash, + INDEX0_SCHEMA_VERSION, + INDEX0_CANONICAL_VIEW_TYPES, + LOCAL_ONLY_VIEW_TYPES, + INDEX0_MAX_CHECKPOINTS_PER_WRITER, + type Index0, + type Index0Context, +} from "./vault/index0.js"; +export { emitEntryEvents, type EmitOptions } from "./vault/index0Events.js"; export { encodeQr, renderQrText, EC_LEVELS, type QrMatrix, type EcLevel } from "./util/qr.js"; export { buildSemanticRecords, diff --git a/js/packages/core/src/sync/canonical.ts b/js/packages/core/src/sync/canonical.ts index 624fcc1e..221e4745 100644 --- a/js/packages/core/src/sync/canonical.ts +++ b/js/packages/core/src/sync/canonical.ts @@ -94,7 +94,7 @@ function pythonFloatRepr(n: number): string { } /** Code-point ordering, matching Python's sorted() on str. */ -function compareCodePoints(a: string, b: string): number { +export function compareCodePoints(a: string, b: string): number { const ai = Array.from(a); const bi = Array.from(b); const len = Math.min(ai.length, bi.length); diff --git a/js/packages/core/src/sync/merge.ts b/js/packages/core/src/sync/merge.ts index 97ec3882..a7901e56 100644 --- a/js/packages/core/src/sync/merge.ts +++ b/js/packages/core/src/sync/merge.ts @@ -13,6 +13,7 @@ */ import { canonicalHash, canonicalJson } from "./canonical.js"; +import { mergeSystemIndex0 } from "../vault/index0.js"; export const TOMBSTONE_RETENTION_CAP = 2048; export const MERGE_STRATEGY = "modified_ts_hash_tombstone_v2"; @@ -264,10 +265,12 @@ function assertIndex0Portable(raw: unknown): void { function ensureIndex0Payload(data: unknown, options: MergeOptions = {}): Dict { const out: Dict = isDict(data) ? { ...data } : {}; const system: Dict = isDict(out["_system"]) ? { ...(out["_system"] as Dict) } : {}; - if (options.index0 !== "preserve-current") { + if (options.index0 === "reject") { assertIndex0Portable(system["index0"]); system["index0"] = emptyIndex0(); } + // "merge" and "preserve-current" both keep the block; which side wins is + // decided in mergeIndexPayloads, where both are in hand. out["_system"] = system; return out; } @@ -332,15 +335,20 @@ export interface MergeOptions { report?: MergeReport; /** - * What to do with `_system.index0`, Python's derived atlas state. + * What to do with `_system.index0`, the derived atlas state. * - * Default ("reject") refuses to merge populated index0 rather than drop - * it silently. "preserve-current" keeps the current side's block verbatim - * and ignores the incoming one — correct when merging remote state into a - * live profile, since Python recomputes canonical views from entries on - * load and would otherwise be blocked entirely. + * Default ("merge") merges both sides with `mergeSystemIndex0`, the same + * deterministic rules Python uses: events union by id, checkpoints, views + * and heads resolve by timestamp then by their own hash. That is now the + * right default because index0 is ported; before it was, the only safe + * choices were to refuse or to keep one side. + * + * "preserve-current" keeps the current side's block verbatim and ignores + * the incoming one. "reject" refuses to merge a populated index0 at all, + * rather than dropping it silently — kept for callers that would rather + * fail than have derived state change under them. */ - index0?: "reject" | "preserve-current"; + index0?: "merge" | "reject" | "preserve-current"; } function describeEntry(entry: Dict): { kind: string; label: string } { @@ -549,8 +557,18 @@ export function mergeIndexPayloads( out["_sync_meta"] = meta; const outSystem: Dict = isDict(out["_system"]) ? (out["_system"] as Dict) : {}; - if (options.index0 !== "preserve-current") { + const mode = options.index0 ?? "merge"; + if (mode === "reject") { + // Populated blocks were already refused above; anything reaching here is + // empty, and is normalized so the output shape is stable. outSystem["index0"] = emptyIndex0(); + } else if (mode === "merge") { + // Both sides' derived state, merged by the same deterministic rules + // Python uses. Doing this from the ORIGINAL payloads rather than from + // `out` matters: `out` has already had entries merged into it, and + // index0 records what each writer did, not what the merge concluded. + const incomingSystem = isDict(incoming["_system"]) ? (incoming["_system"] as Dict) : {}; + outSystem["index0"] = mergeSystemIndex0(outSystem["index0"], incomingSystem["index0"]); } out["_system"] = outSystem; return out; diff --git a/js/packages/core/src/vault/index0.ts b/js/packages/core/src/vault/index0.ts new file mode 100644 index 00000000..37ca04fc --- /dev/null +++ b/js/packages/core/src/vault/index0.ts @@ -0,0 +1,1043 @@ +/** + * index0 / atlas — parity with src/seedpass/core/index0.py. + * + * A per-vault activity ledger that lives inside the encrypted index under + * `_system.index0`. It holds an append-only stream of events per writer, + * daily checkpoints rolling those events up, and derived "canonical views" + * (children, counts by kind, recent activity) that other tooling reads + * instead of walking the whole vault. + * + * Everything here is DERIVED state: it can be rebuilt from the entries and + * the event stream. That is why the port could safely carry it verbatim + * before now — but carrying it verbatim meant a TypeScript client never + * updated it, so 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. + * + * HASH PARITY IS THE WHOLE CONTRACT. Event ids, integrity hashes, checkpoint + * summaries and view hashes are all SHA-256 over Python's canonical JSON, and + * the merge picks winners by comparing those hashes. A single byte of + * disagreement in the encoding means the two implementations disagree about + * which record wins, permanently. Everything routes through `canonicalJson` + * (which already handles Python's ensure_ascii escaping, code-point key + * ordering and number formatting) rather than JSON.stringify. + */ + +import { canonicalJson, canonicalHash, compareCodePoints } from "../sync/canonical.js"; + +export const INDEX0_SCHEMA_VERSION = 1; +export const INDEX0_CHECKPOINT_SUBJECT_CAP = 64; +export const INDEX0_MAX_CHECKPOINTS_PER_WRITER = 30; +export const INDEX0_MANIFEST_CHECKPOINT_LIMIT = 32; +export const INDEX0_RECENT_ACTIVITY_LIMIT = 20; + +/** Views that never leave the machine that built them. */ +export const LOCAL_ONLY_VIEW_TYPES = ["conversation_index", "hot_nodes", "semantic_neighbors"]; +export const INDEX0_CANONICAL_VIEW_TYPES = [ + "children_of", + "counts_by_kind", + "recent_activity", +] as const; + +export type Dict = Record; + +export interface Index0 { + schema_version: number; + events: Record; + checkpoints: Record; + canonical_views: Record; + view_manifest: Dict; + heads: Record; + stats: Dict; +} + +function isDict(value: unknown): value is Dict { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Python's `str(value)`, which is not JavaScript's `String(value)`. + * + * `str(None)` is "None", `str(True)` is "True". Those are quirks rather than + * intentions, but they decide whether a required field counts as present, so + * a vault Python accepted must be accepted here too — and hash identically. + * Diverging would mean the two implementations disagree about which events + * exist, which is worse than agreeing on an odd answer. + */ +function pythonStr(value: unknown): string { + if (value === null || value === undefined) return "None"; + if (value === true) return "True"; + if (value === false) return "False"; + return String(value); +} + +function normalizeString(value: unknown): string { + return pythonStr(value).trim(); +} + +/** + * Python's `int(raw)` with a fallback. + * + * Accepts integers, truncates floats toward zero, and parses decimal strings + * with optional sign, surrounding whitespace and PEP 515 underscores. Anything + * else (including "3.9", "0x10" and NaN) falls back, exactly as the + * `except Exception` in Python does. + */ +function safeInt(raw: unknown, fallback = 0): number { + if (typeof raw === "number") { + if (!Number.isFinite(raw)) return fallback; + return Math.trunc(raw); + } + if (typeof raw === "boolean") return raw ? 1 : 0; + if (typeof raw === "string") { + const text = raw.trim().replace(/_/g, ""); + if (!/^[+-]?\d+$/.test(text)) return fallback; + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; +} + +function normalizeMapping(raw: unknown): Dict { + return isDict(raw) ? { ...raw } : {}; +} + +/** Deduplicated, code-point sorted, blanks dropped — Python's sorted(set(...)). */ +function normalizeTags(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + for (const tag of raw) { + const value = normalizeString(tag); + if (value) seen.add(value); + } + return [...seen].sort(compareCodePoints); +} + +function normalizeLinks(raw: unknown): Dict[] { + if (!Array.isArray(raw)) return []; + // Keyed by canonical form so duplicates collapse, then emitted in key + // order — the same dict-keyed dedupe Python does. + const byKey = new Map(); + for (const item of raw) { + if (!isDict(item)) continue; + const targetId = normalizeString(item["target_id"] ?? ""); + const relation = normalizeString(item["relation"] ?? ""); + const note = normalizeString(item["note"] ?? ""); + if (!targetId || !relation) continue; + const normalized: Dict = { target_id: targetId, relation }; + if (note) normalized["note"] = note; + byKey.set(canonicalJson(normalized), normalized); + } + return [...byKey.keys()].sort(compareCodePoints).map((key) => byKey.get(key)!); +} + +// ------------------------------------------------------------------ hashes + +/** Hash of an event, excluding its own integrity hash. */ +export function computeEventHash(event: Dict): string { + const body = { ...event }; + delete body["integrity_hash"]; + return canonicalHash(body); +} + +export function computeEventId(event: Dict): string { + return `e:${computeEventHash(event)}`; +} + +/** The per-writer head marker: what the next event chains onto. */ +export function computeHeadHash(event: Dict): string { + return canonicalHash({ + event_id: normalizeString(event["event_id"] ?? ""), + integrity_hash: normalizeString(event["integrity_hash"] ?? ""), + prev_hash: normalizeString(event["prev_hash"] ?? ""), + writer_id: normalizeString(event["writer_id"] ?? ""), + }); +} + +export function computeCheckpointHash(checkpoint: Dict): string { + const body = { ...checkpoint }; + delete body["summary_hash"]; + return canonicalHash(body); +} + +export function computeViewHash(view: Dict): string { + const body = { ...view }; + delete body["view_hash"]; + return canonicalHash(body); +} + +/** UTC calendar day, the checkpoint window key. */ +function windowKeyForTs(ts: number): string { + return new Date(safeInt(ts) * 1000).toISOString().slice(0, 10); +} + +function eventSortKey(event: Dict): [number, string] { + return [safeInt(event["modified_ts"] ?? 0), normalizeString(event["event_id"] ?? "")]; +} + +function compareEvents(a: Dict, b: Dict): number { + const [ats, aid] = eventSortKey(a); + const [bts, bid] = eventSortKey(b); + return ats - bts || compareCodePoints(aid, bid); +} + +// ------------------------------------------------------------- normalizers + +export function normalizeIndex0Event(raw: unknown): Dict | null { + if (!isDict(raw)) return null; + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + // A timestamp is what orders the stream; an event without one cannot be + // placed, so it is dropped rather than guessed at. + if (modifiedTs <= 0) return null; + + const event: Dict = { + event_type: normalizeString(raw["event_type"] ?? ""), + subject_type: normalizeString(raw["subject_type"] ?? ""), + subject_id: normalizeString(raw["subject_id"] ?? ""), + subject_kind: normalizeString(raw["subject_kind"] ?? ""), + scope_path: normalizeString(raw["scope_path"] ?? ""), + actor_type: normalizeString(raw["actor_type"] ?? ""), + actor_id: normalizeString(raw["actor_id"] ?? ""), + writer_id: normalizeString(raw["writer_id"] ?? ""), + modified_ts: modifiedTs, + prev_hash: normalizeString(raw["prev_hash"] ?? ""), + classification: normalizeString(raw["classification"] ?? "internal") || "internal", + partition: normalizeString(raw["partition"] ?? "standard") || "standard", + payload_ref: normalizeMapping(raw["payload_ref"]), + links: normalizeLinks(raw["links"]), + tags: normalizeTags(raw["tags"]), + visibility: normalizeString(raw["visibility"] ?? "private") || "private", + }; + // Optional fields are present only when non-empty, because their presence + // changes the hash. + for (const key of ["policy_ref", "source", "source_event_id", "summary"]) { + const value = normalizeString(raw[key] ?? ""); + if (value) event[key] = value; + } + if ( + !event["event_type"] || + !event["subject_type"] || + !event["subject_id"] || + !event["writer_id"] + ) { + return null; + } + + event["integrity_hash"] = normalizeString(raw["integrity_hash"] ?? ""); + const expected = computeEventHash(event); + // A stored hash is preserved even when it disagrees: this normalizes, it + // does not verify, and silently rewriting a mismatched hash would hide + // exactly the tampering the hash exists to reveal. + if (!event["integrity_hash"]) event["integrity_hash"] = expected; + event["event_id"] = normalizeString(raw["event_id"] ?? "") || `e:${expected}`; + return event; +} + +export function normalizeIndex0Checkpoint(raw: unknown): Dict | null { + if (!isDict(raw)) return null; + const checkpointId = normalizeString(raw["checkpoint_id"] ?? ""); + const writerId = normalizeString(raw["writer_id"] ?? ""); + const windowType = normalizeString(raw["window_type"] ?? ""); + const windowKey = normalizeString(raw["window_key"] ?? ""); + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + if (!checkpointId || !writerId || !windowType || !windowKey || modifiedTs <= 0) { + return null; + } + + const rollup = normalizeMapping(raw["rollup"]); + const countMap = (source: unknown): Dict => { + const out: Dict = {}; + for (const key of Object.keys(normalizeMapping(source)).sort(compareCodePoints)) { + const name = normalizeString(key); + if (name) out[name] = safeInt(normalizeMapping(source)[key] ?? 0); + } + return out; + }; + + const checkpoint: Dict = { + checkpoint_id: checkpointId, + window_type: windowType, + window_key: windowKey, + writer_id: writerId, + window_start_ts: safeInt(raw["window_start_ts"] ?? 0), + window_end_ts: safeInt(raw["window_end_ts"] ?? 0), + event_count: Math.max(0, safeInt(raw["event_count"] ?? 0)), + head_hash: normalizeString(raw["head_hash"] ?? ""), + rollup: { + events_by_type: countMap(rollup["events_by_type"]), + subjects_by_kind: countMap(rollup["subjects_by_kind"]), + subjects: normalizeTags(rollup["subjects"]), + }, + modified_ts: modifiedTs, + }; + checkpoint["summary_hash"] = + normalizeString(raw["summary_hash"] ?? "") || computeCheckpointHash(checkpoint); + return checkpoint; +} + +export function normalizeCanonicalView( + raw: unknown, + options: { viewManifest?: Dict | null } = {}, +): Dict | null { + if (!isDict(raw)) return null; + const viewType = normalizeString(raw["view_type"] ?? ""); + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + if (!viewType || modifiedTs <= 0) return null; + // Local-only views must never be written into a shared vault: they are + // derived from data that does not leave the machine, so syncing them would + // publish inferences about it. + if (LOCAL_ONLY_VIEW_TYPES.includes(viewType)) return null; + const manifest = options.viewManifest; + if (manifest) { + const localOnly = manifest["local_only_view_types"]; + if (Array.isArray(localOnly) && localOnly.map(String).includes(viewType)) return null; + } + + const view: Dict = { + view_id: normalizeString(raw["view_id"] ?? ""), + view_type: viewType, + scope_path: normalizeString(raw["scope_path"] ?? ""), + source_checkpoint_ids: normalizeTags(raw["source_checkpoint_ids"]), + source_event_ids: normalizeTags(raw["source_event_ids"]), + data: normalizeMapping(raw["data"]), + modified_ts: modifiedTs, + }; + if (!view["view_id"]) return null; + view["view_hash"] = normalizeString(raw["view_hash"] ?? "") || computeViewHash(view); + return view; +} + +export function normalizeHead(raw: unknown): Dict | null { + if (!isDict(raw)) return null; + const eventId = normalizeString(raw["event_id"] ?? ""); + const headHash = normalizeString(raw["head_hash"] ?? ""); + const modifiedTs = safeInt(raw["modified_ts"] ?? 0); + if (!eventId || !headHash || modifiedTs <= 0) return null; + return { event_id: eventId, head_hash: headHash, modified_ts: modifiedTs }; +} + +export function normalizeViewManifest(raw: unknown): Dict { + const data = normalizeMapping(raw); + const stringSet = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + for (const item of value) { + const text = normalizeString(item); + if (text) seen.add(text); + } + return [...seen].sort(compareCodePoints); + }; + const builderVersions: Dict = {}; + const rawVersions = data["builder_versions"]; + if (isDict(rawVersions)) { + for (const key of Object.keys(rawVersions).sort(compareCodePoints)) { + const name = normalizeString(key); + if (name) builderVersions[name] = safeInt(rawVersions[key] ?? 1, 1); + } + } + return { + version: Math.max(1, safeInt(data["version"] ?? 1, 1)), + canonical_view_types: stringSet(data["canonical_view_types"]), + local_only_view_types: stringSet(data["local_only_view_types"]), + builder_versions: builderVersions, + }; +} + +export function recomputeIndex0Stats(index0: Dict): Dict { + const events = normalizeMapping(index0["events"]); + const checkpoints = normalizeMapping(index0["checkpoints"]); + const heads = normalizeMapping(index0["heads"]); + const timestamps = (source: Dict): number[] => + Object.values(source) + .filter(isDict) + .map((value) => safeInt(value["modified_ts"] ?? 0)); + const checkpointTs = timestamps(checkpoints); + const headTs = timestamps(heads); + return { + event_count: Object.keys(events).length, + checkpoint_count: Object.keys(checkpoints).length, + writer_count: Object.keys(heads).length, + last_compaction_ts: checkpointTs.length ? Math.max(...checkpointTs) : 0, + last_validation_ts: headTs.length ? Math.max(...headTs) : 0, + }; +} + +export function normalizeIndex0(raw: unknown): Index0 { + const data = normalizeMapping(raw); + const normalized: Index0 = { + schema_version: Math.max( + INDEX0_SCHEMA_VERSION, + safeInt(data["schema_version"] ?? INDEX0_SCHEMA_VERSION, 1), + ), + events: {}, + checkpoints: {}, + canonical_views: {}, + view_manifest: {}, + heads: {}, + stats: {}, + }; + for (const [eventId, event] of Object.entries(normalizeMapping(data["events"]))) { + const normalizedEvent = normalizeIndex0Event(event); + if (normalizedEvent) normalized.events[String(eventId)] = normalizedEvent; + } + for (const [id, checkpoint] of Object.entries(normalizeMapping(data["checkpoints"]))) { + const normalizedCheckpoint = normalizeIndex0Checkpoint(checkpoint); + if (normalizedCheckpoint) normalized.checkpoints[String(id)] = normalizedCheckpoint; + } + const viewManifest = normalizeViewManifest(data["view_manifest"]); + normalized.view_manifest = viewManifest; + for (const [viewId, view] of Object.entries(normalizeMapping(data["canonical_views"]))) { + const normalizedView = normalizeCanonicalView(view, { viewManifest }); + if (normalizedView) normalized.canonical_views[String(viewId)] = normalizedView; + } + for (const [writerId, head] of Object.entries(normalizeMapping(data["heads"]))) { + const normalizedHead = normalizeHead(head); + if (normalizedHead) normalized.heads[String(writerId)] = normalizedHead; + } + normalized.stats = recomputeIndex0Stats(normalized as unknown as Dict); + return normalized; +} + +// ------------------------------------------------------------------ context + +export interface Index0Context { + actor_type: string; + actor_id: string; + writer_id: string; + scope_path: string; + root_fingerprint: string; + current_fingerprint: string; +} + +/** + * Who is writing, and where in the seed hierarchy. + * + * A managed account lives at `/accounts/`, and its scope path + * names both so views from a child and its parent do not collide. + */ +export function deriveIndex0Context( + fingerprintDir: string, + options: { actorType?: string } = {}, +): Index0Context { + const parts = fingerprintDir.replace(/\/+$/, "").split("/"); + const currentFp = (parts[parts.length - 1] ?? "").trim(); + const parentName = parts[parts.length - 2] ?? ""; + let rootFp = currentFp; + let scopePath = `seed/${currentFp}`; + if (parentName === "accounts") { + rootFp = (parts[parts.length - 3] ?? "").trim(); + scopePath = `seed/${rootFp}/managed/${currentFp}`; + } + return { + actor_type: normalizeString(options.actorType ?? "user") || "user", + actor_id: currentFp, + writer_id: `writer:profile:${currentFp}`, + scope_path: scopePath, + root_fingerprint: rootFp, + current_fingerprint: currentFp, + }; +} + +/** Ensure `_system.index0` exists and is normalized, without touching entries. */ +export function ensureIndex0Payload(data: unknown): Dict { + const out = isDict(data) ? { ...data } : {}; + const system = isDict(out["_system"]) ? { ...(out["_system"] as Dict) } : {}; + system["index0"] = normalizeIndex0(system["index0"]); + out["_system"] = system; + return out; +} + +// ------------------------------------------------------------------- events + +export interface MakeEventOptions { + eventType: string; + subjectType: string; + subjectId: unknown; + subjectKind: string; + modifiedTs: number; + writerId: string; + actorId: string; + scopePath: string; + actorType?: string; + payloadRef?: Dict | null; + links?: unknown[] | null; + tags?: unknown[] | null; + prevHash?: string; + classification?: string; + partition?: string; + visibility?: string; + policyRef?: string; + source?: string; + sourceEventId?: string; + summary?: string; +} + +export function makeIndex0Event(options: MakeEventOptions): Dict { + const event: Dict = { + event_type: normalizeString(options.eventType), + subject_type: normalizeString(options.subjectType), + subject_id: normalizeString(options.subjectId), + subject_kind: normalizeString(options.subjectKind), + scope_path: normalizeString(options.scopePath), + actor_type: normalizeString(options.actorType ?? "user") || "user", + actor_id: normalizeString(options.actorId), + writer_id: normalizeString(options.writerId), + // A zero or negative timestamp would make the event unorderable and + // normalizeIndex0Event would then discard it, so it is floored at 1. + modified_ts: Math.max(1, safeInt(options.modifiedTs, 1)), + prev_hash: normalizeString(options.prevHash ?? ""), + classification: normalizeString(options.classification ?? "internal") || "internal", + partition: normalizeString(options.partition ?? "standard") || "standard", + payload_ref: normalizeMapping(options.payloadRef), + links: normalizeLinks(options.links), + tags: normalizeTags(options.tags), + visibility: normalizeString(options.visibility ?? "private") || "private", + }; + for (const [key, value] of [ + ["policy_ref", options.policyRef], + ["source", options.source], + ["source_event_id", options.sourceEventId], + ["summary", options.summary], + ] as const) { + const normalized = normalizeString(value ?? ""); + if (normalized) event[key] = normalized; + } + event["integrity_hash"] = computeEventHash(event); + event["event_id"] = computeEventId(event); + return normalizeIndex0Event(event) ?? event; +} + +export interface AppendEventOptions extends Omit< + MakeEventOptions, + "writerId" | "actorId" | "scopePath" | "prevHash" +> { + fingerprintDir: string; +} + +/** + * Append an event to the payload's stream and advance the writer's head. + * + * The event chains onto the writer's current head, so a gap or a rewrite is + * detectable: `prev_hash` names what this writer last wrote. + */ +export function appendIndex0Event(payload: unknown, options: AppendEventOptions): Dict { + const out = ensureIndex0Payload(payload); + const system = (out["_system"] as Dict)["index0"] as unknown as Index0; + const context = deriveIndex0Context(options.fingerprintDir, { + ...(options.actorType !== undefined && { actorType: options.actorType }), + }); + const head = system.heads[context.writer_id]; + const prevHash = isDict(head) ? normalizeString(head["head_hash"] ?? "") : ""; + + const event = makeIndex0Event({ + ...options, + writerId: context.writer_id, + actorId: context.actor_id, + scopePath: context.scope_path, + actorType: context.actor_type, + prevHash, + }); + + system.events[String(event["event_id"])] = event; + system.heads[context.writer_id] = { + event_id: event["event_id"] as string, + head_hash: computeHeadHash(event), + modified_ts: event["modified_ts"] as number, + }; + system.stats = recomputeIndex0Stats(system as unknown as Dict); + return out; +} + +// -------------------------------------------------------------- checkpoints + +export function buildDailyCheckpoint( + writerId: string, + windowKey: string, + events: unknown[], +): Dict | null { + const ordered = events + .map((event) => normalizeIndex0Event(event)) + .filter((event): event is Dict => event !== null) + .sort(compareEvents); + if (ordered.length === 0) return null; + + const latest = ordered[ordered.length - 1]!; + const eventsByType: Record = {}; + const subjectsByKind: Record = {}; + const subjectSet = new Set(); + for (const event of ordered) { + const subjectId = normalizeString(event["subject_id"] ?? ""); + if (subjectId) subjectSet.add(subjectId); + const eventType = normalizeString(event["event_type"] ?? ""); + const subjectKind = normalizeString(event["subject_kind"] ?? ""); + if (eventType) eventsByType[eventType] = (eventsByType[eventType] ?? 0) + 1; + if (subjectKind) subjectsByKind[subjectKind] = (subjectsByKind[subjectKind] ?? 0) + 1; + } + // Capped so one busy day cannot make a checkpoint unbounded. + const subjects = [...subjectSet].sort(compareCodePoints).slice(0, INDEX0_CHECKPOINT_SUBJECT_CAP); + + const sortedCounts = (source: Record): Dict => { + const out: Dict = {}; + for (const key of Object.keys(source).sort(compareCodePoints)) out[key] = source[key]!; + return out; + }; + + const checkpoint: Dict = { + checkpoint_id: `cp:day:${windowKey}:${writerId}`, + window_type: "day", + window_key: windowKey, + writer_id: writerId, + window_start_ts: safeInt(ordered[0]!["modified_ts"] ?? 0), + window_end_ts: safeInt(latest["modified_ts"] ?? 0), + event_count: ordered.length, + head_hash: computeHeadHash(latest), + rollup: { + events_by_type: sortedCounts(eventsByType), + subjects_by_kind: sortedCounts(subjectsByKind), + subjects, + }, + modified_ts: safeInt(latest["modified_ts"] ?? 0), + }; + checkpoint["summary_hash"] = computeCheckpointHash(checkpoint); + return normalizeIndex0Checkpoint(checkpoint); +} + +export function rebuildIndex0Checkpoints( + index0: Dict, + options: { maxCheckpointsPerWriter?: number } = {}, +): Record { + const maxPerWriter = options.maxCheckpointsPerWriter ?? INDEX0_MAX_CHECKPOINTS_PER_WRITER; + const grouped = new Map(); + for (const event of Object.values(normalizeMapping(index0["events"]))) { + const normalized = normalizeIndex0Event(event); + if (!normalized) continue; + const writerId = normalizeString(normalized["writer_id"] ?? ""); + if (!writerId) continue; + const windowKey = windowKeyForTs(safeInt(normalized["modified_ts"] ?? 0)); + // The key joins on a character that cannot appear in a date, so a writer + // id containing the separator cannot forge a different grouping. + const key = `${windowKey}${writerId}`; + const bucket = grouped.get(key) ?? []; + bucket.push(normalized); + grouped.set(key, bucket); + } + + const checkpoints: Record = {}; + const windowsByWriter = new Map>(); + for (const [key, writerEvents] of grouped) { + const [windowKey, writerId] = key.split("") as [string, string]; + const checkpoint = buildDailyCheckpoint(writerId, windowKey, writerEvents); + if (!checkpoint) continue; + checkpoints[String(checkpoint["checkpoint_id"])] = checkpoint; + const windows = windowsByWriter.get(writerId) ?? new Set(); + windows.add(windowKey); + windowsByWriter.set(writerId, windows); + } + + // Keep the most recent windows per writer, so history is bounded without + // one noisy writer evicting another's checkpoints. + const retained = new Set(); + for (const [writerId, windows] of windowsByWriter) { + const keep = [...windows].sort(compareCodePoints).reverse().slice(0, Math.max(1, maxPerWriter)); + for (const windowKey of keep) retained.add(`cp:day:${windowKey}:${writerId}`); + } + + const out: Record = {}; + for (const id of [...retained].sort(compareCodePoints)) { + if (checkpoints[id]) out[id] = checkpoints[id]!; + } + return out; +} + +export function compactIndex0( + index0: unknown, + options: { maxCheckpointsPerWriter?: number } = {}, +): Index0 { + const normalized = normalizeIndex0(index0); + normalized.checkpoints = rebuildIndex0Checkpoints( + normalized as unknown as Dict, + options, + ); + normalized.stats = recomputeIndex0Stats(normalized as unknown as Dict); + return normalized; +} + +// ------------------------------------------------------------------- views + +function normalizeEntrySummary(entryId: string, entry: Dict): Dict { + const kind = normalizeString(entry["kind"] ?? entry["type"] ?? "password") || "password"; + return { + entry_id: normalizeString(entryId), + kind, + label: normalizeString(entry["label"] ?? entry["website"] ?? ""), + archived: Boolean(entry["archived"] ?? entry["blacklisted"] ?? false), + modified_ts: safeInt(entry["modified_ts"] ?? entry["updated"] ?? 0), + link_count: normalizeLinks(entry["links"] ?? []).length, + tag_count: normalizeTags(entry["tags"] ?? []).length, + }; +} + +/** + * Order entry ids numerically, tolerating ids that are not numbers. + * + * Entry ids are normally decimal strings, but an index that has been hand + * edited, partially restored, or written by another tool can hold anything. + * A malformed id must degrade the ordering, not break view building — for a + * password manager, a vault that will not open is indistinguishable from one + * that is lost. + */ +function compareEntryIds(a: string, b: string): number { + const an = /^[+-]?\d+$/.test(a.trim()) ? Number(a.trim()) : null; + const bn = /^[+-]?\d+$/.test(b.trim()) ? Number(b.trim()) : null; + if (an !== null && bn !== null) return an - bn; + if (an !== null) return -1; + if (bn !== null) return 1; + return compareCodePoints(a, b); +} + +function buildChildrenView( + scopePath: string, + entries: Dict, + eventIds: string[], + checkpointIds: string[], +): Dict { + const items = Object.keys(normalizeMapping(entries)) + .sort(compareEntryIds) + .filter((id) => isDict(normalizeMapping(entries)[id])) + .map((id) => normalizeEntrySummary(id, normalizeMapping(entries)[id] as Dict)); + const timestamps = items.map((item) => item["modified_ts"] as number).filter((ts) => ts > 0); + return { + view_id: `children_of:${scopePath}`, + view_type: "children_of", + scope_path: scopePath, + source_checkpoint_ids: checkpointIds, + source_event_ids: eventIds, + data: { children: items, total_children: items.length }, + modified_ts: Math.max(1, timestamps.length ? Math.max(...timestamps) : 0), + }; +} + +function buildCountsView( + scopePath: string, + entries: Dict, + eventIds: string[], + checkpointIds: string[], +): Dict { + const counts: Record = {}; + let archivedCount = 0; + const timestamps: number[] = []; + for (const entry of Object.values(normalizeMapping(entries))) { + if (!isDict(entry)) continue; + const kind = normalizeString(entry["kind"] ?? entry["type"] ?? "password"); + if (kind) counts[kind] = (counts[kind] ?? 0) + 1; + if (Boolean(entry["archived"] ?? entry["blacklisted"] ?? false)) archivedCount++; + timestamps.push(safeInt(entry["modified_ts"] ?? entry["updated"] ?? 0)); + } + const sorted: Dict = {}; + for (const key of Object.keys(counts).sort(compareCodePoints)) sorted[key] = counts[key]!; + return { + view_id: `counts_by_kind:${scopePath}`, + view_type: "counts_by_kind", + scope_path: scopePath, + source_checkpoint_ids: checkpointIds, + source_event_ids: eventIds, + data: { + counts: sorted, + archived_count: archivedCount, + total_entries: Object.values(counts).reduce((a, b) => a + b, 0), + }, + modified_ts: Math.max(1, timestamps.length ? Math.max(...timestamps) : 0), + }; +} + +function buildRecentActivityView( + scopePath: string, + events: Dict[], + checkpointIds: string[], +): Dict { + const ordered = [...events].sort((a, b) => -compareEvents(a, b)).slice( + 0, + INDEX0_RECENT_ACTIVITY_LIMIT, + ); + const items = ordered.map((event) => ({ + event_id: normalizeString(event["event_id"] ?? ""), + event_type: normalizeString(event["event_type"] ?? ""), + subject_id: normalizeString(event["subject_id"] ?? ""), + subject_kind: normalizeString(event["subject_kind"] ?? ""), + modified_ts: safeInt(event["modified_ts"] ?? 0), + summary: normalizeString(event["summary"] ?? ""), + })); + const timestamps = items.map((item) => item.modified_ts).filter((ts) => ts > 0); + return { + view_id: `recent_activity:${scopePath}`, + view_type: "recent_activity", + scope_path: scopePath, + source_checkpoint_ids: checkpointIds, + source_event_ids: items.map((item) => item.event_id).filter((id) => id), + data: { items, total_items: items.length }, + modified_ts: Math.max(1, timestamps.length ? Math.max(...timestamps) : 0), + }; +} + +/** Rebuild every canonical view from the entries and the event stream. */ +export function rebuildCanonicalViewsPayload( + payload: unknown, + options: { fingerprintDir?: string | null } = {}, +): Dict { + const out = ensureIndex0Payload(payload); + const index0 = (out["_system"] as Dict)["index0"] as unknown as Index0; + const entries = normalizeMapping(out["entries"]); + const events = Object.values(index0.events) + .map((event) => normalizeIndex0Event(event)) + .filter((event): event is Dict => event !== null); + const checkpoints = normalizeMapping(index0.checkpoints); + + const scopePaths = new Set(); + for (const event of events) { + const scope = normalizeString(event["scope_path"] ?? ""); + if (scope) scopePaths.add(scope); + } + if (options.fingerprintDir !== undefined && options.fingerprintDir !== null) { + scopePaths.add(deriveIndex0Context(options.fingerprintDir).scope_path); + } + + const views: Record = {}; + for (const scopePath of [...scopePaths].filter((s) => s).sort(compareCodePoints)) { + const scopeEvents = events.filter( + (event) => normalizeString(event["scope_path"] ?? "") === scopePath, + ); + const checkpointIds = Object.entries(checkpoints) + .filter( + ([, checkpoint]) => + isDict(checkpoint) && + scopeEvents.some( + (event) => + normalizeString(event["writer_id"] ?? "") === + normalizeString((checkpoint as Dict)["writer_id"] ?? ""), + ), + ) + .map(([id]) => id) + .sort(compareCodePoints); + const sourceEventIds = [ + ...new Set( + scopeEvents + .map((event) => normalizeString(event["event_id"] ?? "")) + .filter((id) => id), + ), + ].sort(compareCodePoints); + + for (const rawView of [ + buildChildrenView(scopePath, entries, sourceEventIds, checkpointIds), + buildCountsView(scopePath, entries, sourceEventIds, checkpointIds), + buildRecentActivityView(scopePath, scopeEvents, checkpointIds), + ]) { + rawView["view_hash"] = computeViewHash(rawView); + const normalized = normalizeCanonicalView(rawView, { + viewManifest: { local_only_view_types: [...LOCAL_ONLY_VIEW_TYPES].sort(compareCodePoints) }, + }); + if (normalized) views[String(normalized["view_id"])] = normalized; + } + } + + index0.view_manifest = normalizeViewManifest({ + version: 1, + canonical_view_types: [...INDEX0_CANONICAL_VIEW_TYPES], + local_only_view_types: [...LOCAL_ONLY_VIEW_TYPES].sort(compareCodePoints), + builder_versions: Object.fromEntries( + INDEX0_CANONICAL_VIEW_TYPES.map((viewType) => [viewType, 1]), + ), + }); + const sortedViews: Record = {}; + for (const key of Object.keys(views).sort(compareCodePoints)) sortedViews[key] = views[key]!; + index0.canonical_views = sortedViews; + index0.stats = recomputeIndex0Stats(index0 as unknown as Dict); + (out["_system"] as Dict)["index0"] = index0; + return out; +} + +export function compactIndex0Payload( + payload: unknown, + options: { maxCheckpointsPerWriter?: number; fingerprintDir?: string | null } = {}, +): Dict { + const out = ensureIndex0Payload(payload); + (out["_system"] as Dict)["index0"] = compactIndex0((out["_system"] as Dict)["index0"], { + ...(options.maxCheckpointsPerWriter !== undefined && { + maxCheckpointsPerWriter: options.maxCheckpointsPerWriter, + }), + }); + return rebuildCanonicalViewsPayload(out, { + ...(options.fingerprintDir !== undefined && { fingerprintDir: options.fingerprintDir }), + }); +} + +/** The index0 summary that goes into a sync manifest. */ +export function buildManifestIndex0Metadata( + payload: unknown, + options: { checkpointLimit?: number; fingerprintDir?: string | null } = {}, +): Dict { + const limit = options.checkpointLimit ?? INDEX0_MANIFEST_CHECKPOINT_LIMIT; + const compacted = compactIndex0Payload(payload, { + ...(options.fingerprintDir !== undefined && { fingerprintDir: options.fingerprintDir }), + }); + const index0 = (compacted["_system"] as Dict)["index0"] as unknown as Index0; + const checkpoints = Object.values(index0.checkpoints).filter(isDict); + const selected = [...checkpoints] + .sort((a, b) => { + const ats = safeInt(a["modified_ts"] ?? 0); + const bts = safeInt(b["modified_ts"] ?? 0); + if (ats !== bts) return bts - ats; + return compareCodePoints( + normalizeString(b["checkpoint_id"] ?? ""), + normalizeString(a["checkpoint_id"] ?? ""), + ); + }) + .slice(0, Math.max(0, limit)); + const checkpointIds = selected + .map((checkpoint) => normalizeString(checkpoint["checkpoint_id"] ?? "")) + .filter((id) => id); + + const checkpointHashes: Dict = {}; + for (const id of checkpointIds) { + const checkpoint = index0.checkpoints[id]; + if (checkpoint) checkpointHashes[id] = normalizeString(checkpoint["summary_hash"] ?? ""); + } + const streamHeads: Dict = {}; + for (const writerId of Object.keys(index0.heads).sort(compareCodePoints)) { + const head = index0.heads[writerId]!; + const hash = normalizeString(head["head_hash"] ?? ""); + if (hash) streamHeads[writerId] = hash; + } + return { + schema_version: INDEX0_SCHEMA_VERSION, + checkpoint_ids: checkpointIds, + checkpoint_hashes: checkpointHashes, + stream_heads: streamHeads, + }; +} + +export function listCanonicalViews(index0: unknown): Dict[] { + const normalized = normalizeIndex0(index0); + return Object.keys(normalized.canonical_views) + .sort(compareCodePoints) + .map((key) => normalized.canonical_views[key]!); +} + +export function getCanonicalView( + index0: unknown, + options: { viewType: string; scopePath: string }, +): Dict | null { + const normalized = normalizeIndex0(index0); + const viewId = `${normalizeString(options.viewType)}:${normalizeString(options.scopePath)}`; + const view = normalized.canonical_views[viewId]; + return isDict(view) ? { ...view } : null; +} + +// ------------------------------------------------------------------- merge + +/** + * Later timestamp wins; equal timestamps break on the record's own hash. + * + * The hash tie-break is what makes the merge deterministic across replicas — + * both sides compute the same winner without talking to each other. + */ +function preferHashedRecord(current: Dict, incoming: Dict, hashField: string): boolean { + const curTs = safeInt(current["modified_ts"] ?? 0); + const incTs = safeInt(incoming["modified_ts"] ?? 0); + if (incTs !== curTs) return incTs > curTs; + return ( + compareCodePoints( + normalizeString(incoming[hashField] ?? ""), + normalizeString(current[hashField] ?? ""), + ) > 0 + ); +} + +export function mergeSystemIndex0(current: unknown, incoming: unknown): Index0 { + const cur = normalizeIndex0(current); + const inc = normalizeIndex0(incoming); + + const events: Record = { ...cur.events }; + for (const [eventId, event] of Object.entries(inc.events)) { + const existing = events[eventId]; + if (!existing) { + events[eventId] = event; + continue; + } + if (canonicalJson(existing) === canonicalJson(event)) continue; + // Two different events claiming one id: pick by hash so both replicas + // reach the same answer. + if (compareCodePoints(canonicalHash(event), canonicalHash(existing)) > 0) { + events[eventId] = event; + } + } + + const checkpoints: Record = { ...cur.checkpoints }; + for (const [id, checkpoint] of Object.entries(inc.checkpoints)) { + const existing = checkpoints[id]; + if (!existing || preferHashedRecord(existing, checkpoint, "summary_hash")) { + checkpoints[id] = checkpoint; + } + } + + const union = (a: unknown, b: unknown): string[] => { + const out = new Set(); + for (const list of [a, b]) { + if (Array.isArray(list)) for (const item of list) out.add(String(item)); + } + return [...out].sort(compareCodePoints); + }; + const viewManifest = normalizeViewManifest({ + version: Math.max( + safeInt(cur.view_manifest["version"] ?? 1, 1), + safeInt(inc.view_manifest["version"] ?? 1, 1), + ), + canonical_view_types: union( + cur.view_manifest["canonical_view_types"], + inc.view_manifest["canonical_view_types"], + ), + local_only_view_types: union( + cur.view_manifest["local_only_view_types"], + inc.view_manifest["local_only_view_types"], + ), + builder_versions: { + ...normalizeMapping(cur.view_manifest["builder_versions"]), + ...normalizeMapping(inc.view_manifest["builder_versions"]), + }, + }); + + const views: Record = { ...cur.canonical_views }; + for (const [viewId, view] of Object.entries(inc.canonical_views)) { + const normalized = normalizeCanonicalView(view, { viewManifest }); + if (!normalized) continue; + const existing = views[viewId]; + if (!existing || preferHashedRecord(existing, normalized, "view_hash")) { + views[viewId] = normalized; + } + } + + const heads: Record = { ...cur.heads }; + for (const [writerId, head] of Object.entries(inc.heads)) { + const existing = heads[writerId]; + if (!existing || preferHashedRecord(existing, head, "head_hash")) { + heads[writerId] = head; + } + } + + const sortRecords = (source: Record): Record => { + const out: Record = {}; + for (const key of Object.keys(source).sort(compareCodePoints)) out[key] = source[key]!; + return out; + }; + + const merged: Index0 = { + schema_version: Math.max(cur.schema_version, inc.schema_version), + events: sortRecords(events), + checkpoints: sortRecords(checkpoints), + canonical_views: sortRecords(views), + view_manifest: viewManifest, + heads: sortRecords(heads), + stats: {}, + }; + merged.stats = recomputeIndex0Stats(merged as unknown as Dict); + return merged; +} diff --git a/js/packages/core/src/vault/index0Events.ts b/js/packages/core/src/vault/index0Events.ts new file mode 100644 index 00000000..277bee10 --- /dev/null +++ b/js/packages/core/src/vault/index0Events.ts @@ -0,0 +1,118 @@ +/** + * Emit index0 events for whatever a vault mutation actually changed. + * + * Python emits from each call site in EntryManager, naming the operation it + * just performed. This derives the events by comparing the index before and + * after instead, and is wired into the single mutation funnel every surface + * goes through (CLI, TUI, API). + * + * That is a deliberate difference, for one reason: a call-site emitter is + * something a new code path can forget, and a silently incomplete activity + * ledger is worse than an absent one — it reads as "nothing happened". A + * funnel-level differ cannot be forgotten, because there is nowhere else to + * write a change from. The event VOCABULARY matches Python's + * (entry_created / entry_updated / entry_deleted), so a stream from either + * implementation reads the same. + */ + +import { appendIndex0Event, type Dict } from "./index0.js"; + +function isDict(value: unknown): value is Dict { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function kindOf(entry: Dict): string { + return String(entry["kind"] ?? entry["type"] ?? "").trim() || "entry"; +} + +function tsOf(entry: Dict, fallback: number): number { + const raw = Number(entry["modified_ts"] ?? 0); + return Number.isFinite(raw) && raw > 0 ? Math.trunc(raw) : fallback; +} + +export interface EmitOptions { + /** Entries as they were before the mutation. */ + before: Record; + /** Entries as they are after it. */ + after: Record; + fingerprintDir: string; + /** Seconds since the epoch, for entries carrying no timestamp of their own. */ + now: number; +} + +/** + * Append one event per changed entry, returning the updated payload. + * + * Comparison is by canonical JSON of the whole entry, so a change to any + * field counts — including ones no specific call site would have thought to + * report. + */ +export function emitEntryEvents(payload: unknown, options: EmitOptions): Dict { + const { before, after, fingerprintDir, now } = options; + let out = payload as Dict; + + const ids = new Set([...Object.keys(before), ...Object.keys(after)]); + // Sorted so the emitted order is deterministic: two replicas performing the + // same batch must produce the same stream. + for (const id of [...ids].sort((a, b) => { + const an = Number(a); + const bn = Number(b); + if (Number.isInteger(an) && Number.isInteger(bn)) return an - bn; + return a < b ? -1 : a > b ? 1 : 0; + })) { + const oldEntry = before[id]; + const newEntry = after[id]; + const had = isDict(oldEntry); + const has = isDict(newEntry); + if (!had && !has) continue; + + if (has && !had) { + out = appendIndex0Event(out, { + eventType: "entry_created", + subjectType: "entry", + subjectId: id, + subjectKind: kindOf(newEntry), + modifiedTs: tsOf(newEntry, now), + fingerprintDir, + payloadRef: { entry_id: id }, + links: Array.isArray(newEntry["links"]) ? (newEntry["links"] as unknown[]) : [], + tags: Array.isArray(newEntry["tags"]) ? (newEntry["tags"] as unknown[]) : [], + summary: `Created ${kindOf(newEntry)} entry`, + source: "entry_management", + }); + continue; + } + + if (had && !has) { + out = appendIndex0Event(out, { + eventType: "entry_deleted", + subjectType: "entry", + subjectId: id, + subjectKind: kindOf(oldEntry), + modifiedTs: now, + fingerprintDir, + payloadRef: { entry_id: id }, + summary: `Deleted ${kindOf(oldEntry)} entry`, + source: "entry_management", + }); + continue; + } + + if (had && has && JSON.stringify(oldEntry) !== JSON.stringify(newEntry)) { + out = appendIndex0Event(out, { + eventType: "entry_updated", + subjectType: "entry", + subjectId: id, + subjectKind: kindOf(newEntry), + modifiedTs: tsOf(newEntry, now), + fingerprintDir, + payloadRef: { entry_id: id }, + links: Array.isArray(newEntry["links"]) ? (newEntry["links"] as unknown[]) : [], + tags: Array.isArray(newEntry["tags"]) ? (newEntry["tags"] as unknown[]) : [], + summary: `Updated ${kindOf(newEntry)} entry`, + source: "entry_management", + }); + } + } + return out; +} diff --git a/js/packages/core/test/index0.test.ts b/js/packages/core/test/index0.test.ts new file mode 100644 index 00000000..47053364 --- /dev/null +++ b/js/packages/core/test/index0.test.ts @@ -0,0 +1,242 @@ +/** + * index0 / atlas. + * + * This is derived state whose whole contract is hash parity: 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 disagreement in the encoding means the two + * implementations permanently disagree about which record wins. + * + * So the load-bearing tests compare whole structures against fixtures the + * Python implementation produced, via canonicalJson rather than toEqual — + * an assertion that passes only if the bytes that get hashed are identical. + */ + +import { describe, expect, it } from "vitest"; +import { + appendIndex0Event, + compactIndex0Payload, + buildManifestIndex0Metadata, + mergeSystemIndex0, + deriveIndex0Context, + listCanonicalViews, + getCanonicalView, + normalizeIndex0, + normalizeIndex0Event, + rebuildCanonicalViewsPayload, + emitEntryEvents, + canonicalJson, + LOCAL_ONLY_VIEW_TYPES, +} from "@seedpass/core"; +import { index0Fixture as fx } from "@seedpass/test-vectors"; + +const FP = "/home/user/.seedpass/C557EEC878DFD852"; +const MANAGED = "/home/user/.seedpass/C557EEC878DFD852/accounts/AABBCCDDEEFF0011"; + +/** Rebuild the fixture's event stream, event for event. */ +function buildAppended(): Record { + let payload: unknown = { schema_version: 4, entries: fx.entries }; + const specs = [ + [1700000100, "entry.created", "0", "password"], + [1700000200, "entry.created", "1", "totp"], + [1700086400, "entry.updated", "0", "password"], + [1700086500, "entry.archived", "1", "totp"], + ] as const; + specs.forEach(([ts, eventType, subjectId, subjectKind], i) => { + payload = appendIndex0Event(payload, { + eventType, + subjectType: "entry", + subjectId, + subjectKind, + modifiedTs: ts, + fingerprintDir: FP, + tags: ["auto"], + summary: `event ${i} — ünï`, + }); + }); + return appendIndex0Event(payload, { + eventType: "entry.created", + subjectType: "entry", + subjectId: "7", + subjectKind: "seed", + modifiedTs: 1700090000, + fingerprintDir: MANAGED, + }); +} + +describe("byte-for-byte parity with Python", () => { + it("derives the same writer context, including managed accounts", () => { + expect(deriveIndex0Context(FP)).toEqual(fx.context_root); + // A managed account's scope names both the root and the child, so views + // from a parent and its child cannot collide. + expect(deriveIndex0Context(MANAGED)).toEqual(fx.context_managed); + }); + + it("produces an identical event stream, hashes and heads", () => { + // Every event id is a hash of the event, and each chains onto the + // writer's previous head — so this passing means the canonical encoding, + // the field set and the chaining all match. + expect(canonicalJson(buildAppended())).toBe(canonicalJson(fx.appended)); + }); + + it("compacts to identical checkpoints and views", () => { + const compacted = compactIndex0Payload(buildAppended(), { fingerprintDir: FP }); + expect(canonicalJson(compacted)).toBe(canonicalJson(fx.compacted)); + }); + + it("produces identical manifest metadata", () => { + expect( + canonicalJson(buildManifestIndex0Metadata(buildAppended(), { fingerprintDir: FP })), + ).toBe(canonicalJson(fx.manifest_meta)); + }); + + it("merges two replicas to an identical result", () => { + const compacted = compactIndex0Payload(buildAppended(), { fingerprintDir: FP }); + const mine = (compacted as Record)["_system"].index0; + const theirs = (fx.other as Record)["_system"].index0; + expect(canonicalJson(mergeSystemIndex0(mine, theirs))).toBe(canonicalJson(fx.merged)); + }); + + it("lists and fetches views identically", () => { + const index0 = (compactIndex0Payload(buildAppended(), { fingerprintDir: FP }) as Record< + string, + any + >)["_system"].index0; + expect(canonicalJson(listCanonicalViews(index0))).toBe(canonicalJson(fx.views)); + expect( + canonicalJson( + getCanonicalView(index0, { + viewType: "counts_by_kind", + scopePath: fx.context_root["scope_path"]!, + }), + ), + ).toBe(canonicalJson(fx.one_view)); + }); +}); + +describe("normalization refuses what it cannot place", () => { + it("drops events with no usable timestamp", () => { + // A timestamp is what orders the stream; without one the event cannot be + // placed, and guessing would corrupt the ordering silently. + for (const bad of [0, -1, "", "abc", null]) { + expect(normalizeIndex0Event({ event_type: "x", subject_type: "entry", subject_id: "1", writer_id: "w", modified_ts: bad })).toBeNull(); + } + }); + + it("drops events missing a required field", () => { + const base = { + event_type: "entry_created", + subject_type: "entry", + subject_id: "1", + writer_id: "writer:profile:AAAA", + modified_ts: 1700000000, + }; + expect(normalizeIndex0Event(base)).not.toBeNull(); + for (const field of ["event_type", "subject_type", "subject_id", "writer_id"]) { + expect(normalizeIndex0Event({ ...base, [field]: "" })).toBeNull(); + } + }); + + it("preserves a stored integrity hash even when it disagrees", () => { + // This normalizes; it does not verify. Rewriting a mismatched hash would + // hide exactly the tampering the hash exists to reveal. + const event = normalizeIndex0Event({ + event_type: "entry_created", + subject_type: "entry", + subject_id: "1", + writer_id: "w", + modified_ts: 1700000000, + integrity_hash: "deadbeef", + })!; + expect(event["integrity_hash"]).toBe("deadbeef"); + }); + + it("never admits a local-only view into shared state", () => { + // These are derived from data that does not leave the machine, so + // syncing them would publish inferences about it. + for (const viewType of LOCAL_ONLY_VIEW_TYPES) { + const normalized = normalizeIndex0({ + canonical_views: { + "x:y": { view_id: "x:y", view_type: viewType, modified_ts: 1700000000 }, + }, + }); + expect(normalized.canonical_views).toEqual({}); + } + }); + + it("survives a malformed entry id rather than failing to build views", () => { + // A vault that will not open is indistinguishable from one that is lost, + // so a hand-edited id degrades the ordering instead of throwing. + const rebuilt = rebuildCanonicalViewsPayload( + { entries: { "2": { kind: "password", label: "b" }, "not-a-number": { kind: "password", label: "a" } } }, + { fingerprintDir: FP }, + ); + const view = getCanonicalView((rebuilt as any)._system.index0, { + viewType: "children_of", + scopePath: deriveIndex0Context(FP).scope_path, + })!; + const ids = ((view["data"] as any).children as any[]).map((c) => c.entry_id); + // Numeric ids first, non-numeric after — the fixture proves Python agrees. + expect(ids).toEqual(["2", "not-a-number"]); + }); + + it("tolerates junk in place of the whole block", () => { + for (const junk of [null, "string", 42, []]) { + const normalized = normalizeIndex0(junk); + expect(normalized.events).toEqual({}); + expect(normalized.stats["event_count"]).toBe(0); + } + }); +}); + +describe("the differ records what a mutation changed", () => { + const base = { schema_version: 4, entries: {} as Record }; + + it("emits create, update and delete with Python's vocabulary", () => { + const before = { "0": { kind: "password", label: "a", modified_ts: 1700000000 } }; + const after = { + "0": { kind: "password", label: "renamed", modified_ts: 1700000500 }, + "1": { kind: "totp", label: "new", modified_ts: 1700000600 }, + }; + const out = emitEntryEvents( + { ...base, entries: after, _system: { index0: { events: {} } } }, + { before, after, fingerprintDir: FP, now: 1700000700 }, + ); + const events = Object.values((out as any)._system.index0.events) as any[]; + const byType = events.map((e) => e.event_type).sort(); + expect(byType).toEqual(["entry_created", "entry_updated"]); + + const deleted = emitEntryEvents( + { ...base, entries: {}, _system: { index0: { events: {} } } }, + { before, after: {}, fingerprintDir: FP, now: 1700000700 }, + ); + const deletedEvents = Object.values((deleted as any)._system.index0.events) as any[]; + expect(deletedEvents[0].event_type).toBe("entry_deleted"); + expect(deletedEvents[0].subject_id).toBe("0"); + }); + + it("emits nothing when nothing changed", () => { + const entries = { "0": { kind: "password", label: "a", modified_ts: 1700000000 } }; + const out = emitEntryEvents( + { ...base, entries, _system: { index0: { events: {} } } }, + { before: entries, after: entries, fingerprintDir: FP, now: 1700000700 }, + ); + expect(Object.keys((out as any)._system.index0.events)).toHaveLength(0); + }); + + it("emits in a deterministic order, so two replicas agree", () => { + const after = { + "10": { kind: "password", label: "j", modified_ts: 1700000010 }, + "2": { kind: "password", label: "b", modified_ts: 1700000002 }, + "1": { kind: "password", label: "a", modified_ts: 1700000001 }, + }; + const run = () => + canonicalJson( + emitEntryEvents( + { ...base, entries: after, _system: { index0: { events: {} } } }, + { before: {}, after, fingerprintDir: FP, now: 1700000700 }, + ), + ); + expect(run()).toBe(run()); + }); +}); diff --git a/js/packages/test-vectors/fixtures/high_risk.json b/js/packages/test-vectors/fixtures/high_risk.json index 3eec16e4..222d9e14 100644 --- a/js/packages/test-vectors/fixtures/high_risk.json +++ b/js/packages/test-vectors/fixtures/high_risk.json @@ -25,11 +25,11 @@ "kdf": "pbkdf2-sha256", "salt_b64": "AAECAwQFBgcICQoLDA0ODw==", "version": 1, - "wrapped_partition_key": "gAAAAABqhbsiuZu-EfSBrvoDDpPRJXSPP30W1kGfWZz-WMbSHKjqJn_qHWYfiwi-4xhlczaRZKTUwANB-UIAo5XkAi7MbNwBlT_ElZaSQkskX85zbTuC8sbioHEUT1kASzV6rIbbta0e" + "wrapped_partition_key": "gAAAAABlU_EAAAECAwQFBgcICQoLDA0OD5IWjg3AYnG2ZBi9dQctSsCrYOHkdaqFp5aGi3XpMl4t2OXcBZi1glMl1L2NHKqL4XsvRw--CptMCQgvuHnFA3iNF_1d8dib4NxUmgQ0NRnd" }, "factor": "second-factor-passphrase", "fixture_version": 1, - "partition_file_b64": "Z0FBQUFBQnFoYnNpU2FoTjlTZDF1MUNwV1JwLVRqNTRiWlplWXRRTXZhVzdqa2FoYUVMUl84NDVmdzdoNjJucFgzUzFWbFh5aGRuOW9UcWNfY3lLMTZpSTYySGo4LXVJM0tOYW0tVmc0bHhtOGVITkd4MWw4VEJOeGk3eF9NZWloQmoxWUJkaTg3RUlZdHZnWkVobE00cHQ0NzVucnRTNlJYR0RPMnozYV9pTVl0SVRqcjE3eUdRUkVwVzBuMHlCcGJxZ2VZbHRaallmclpIdlNkTnpwczRIdF9BeWVITHQtTER3S0tPMU9ZZU41VkkwWlRKdFg5eEw3SFIycm44MnNKTzNHVUFYVTNBWFRjbEx3Ri1vLVYxY2Y3cmQxUmstTHBSX0tvekZFZUxfdmZsV05aMGJ5WXgxMHFvbFlXZ1VYbTVyUWJ4ZG9BUXdFRklLVS1YZUxOTW1FekVVcGpXUGlicFktbm52NmR3Nlk0aW9RdUFGZmZwRlpnN091dDh5NUlqOXFwVEpGeGZSdEROVUtfTzhiUVdFamZqSHo5RGF1TmV0YWkzNkwyS3FnX0lJVTducEdzZ0xJbEVvRVFfX09Va1Byb0lWaTdMNlFnSDRmYUNpb2UxVGlsU19lQWMzRS1oRlZQSHpOM1ptRG05dU16QjdUQVZycGxWcUdlNWEtRjBoNkNncUU4SVo=", + "partition_file_b64": "Z0FBQUFBQmxVX0VBQUFFQ0F3UUZCZ2NJQ1FvTERBME9EOWZDeDVfMXBmbkNTRGc4cEk0SWk1b1FxQzNJaFNScTllWEsyT2hTWkQwUWhCX3JiUkE5RXFzTWYzS1pXT2F4OE5kTDJfZW9FNHRiVUNmZGJ1Nl9JT2ZFbHUxV243SWdqbnlDUjZac1J6ZFpVaDVITTlZb0RhVDQ3MDB5VEJON2ZUcVlRZXNPSnFsMWx2Si11eGw1VTVBLXF1MHJwVWx3cWlmWldvQnBDeDZ5QnY2RjcwbWdfT3lyNFVCWm1mYjk4SW41U19nck50dWFmV1lGSkFCdFA5eXFubU9MUHFfZ0RZNDVOVGJTX0F3ODRSX1JsRGJ0bXpuSElvNHAweWpQZC14SmlUakNzSTY0ejl5cElsTVRFWlRhemhGVEl4QTJEdlp6akdaR0VjS3R0dW9jb2RQa2FtQ1VGNEVCalhIdUh5a3hsdXpCcVZ2YXlCb2JQc2l2VlQxSGlfaVlyVjVsRjhsZVFBVnBubDZPdFNDZF9HbDdnZG1RSFhuZzdxRVVPVHprYXBKaC16NlhXTFRLZjhEQkZqZ3BpeEh5bnBWandWdDI2U29ZeXdlNVA5TVU5N3J5dk8yOHNNV1hqZnhIdktDa0hCRnJpMFlPV2FNTE9EbW8wZkpXLUJKZC05WGxKRnhHV0ZSSTR0WjA=", "partition_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "tag": "905f28def18eaac05ae6f12b2c3452744afaf626da1343d57b395b544e0519b6" } diff --git a/js/packages/test-vectors/fixtures/index0.json b/js/packages/test-vectors/fixtures/index0.json new file mode 100644 index 00000000..cfce9340 --- /dev/null +++ b/js/packages/test-vectors/fixtures/index0.json @@ -0,0 +1,1708 @@ +{ + "appended": { + "_system": { + "index0": { + "canonical_views": {}, + "checkpoints": {}, + "events": { + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "classification": "internal", + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "integrity_hash": "06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "links": [], + "modified_ts": 1700090000, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "subject_id": "7", + "subject_kind": "seed", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "integrity_hash": "a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "links": [], + "modified_ts": 1700000200, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "7ce12c9b183701d408c08aa95c8a47bab41e9b9bdefe20cd158359b0eff7adfb", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 1 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "integrity_hash": "b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "links": [], + "modified_ts": 1700086400, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 2 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "integrity_hash": "e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "links": [], + "modified_ts": 1700086500, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "5490e096426699e9c417b4f470d4b5ca5175f7805d9e3bac850b736c8dc90186", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 3 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "integrity_hash": "f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "links": [], + "modified_ts": 1700000100, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 0 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:AABBCCDDEEFF0011": { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000 + }, + "writer:profile:C557EEC878DFD852": { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 0, + "event_count": 5, + "last_compaction_ts": 0, + "last_validation_ts": 1700090000, + "writer_count": 2 + }, + "view_manifest": { + "builder_versions": {}, + "canonical_view_types": [], + "local_only_view_types": [], + "version": 1 + } + } + }, + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "schema_version": 4 + }, + "compacted": { + "_system": { + "index0": { + "canonical_views": { + "children_of:seed/C557EEC878DFD852": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "3bf8aa8e904e217efa279005d1d4c7d5e7dc45f922661733655ca3df9b58a59b", + "view_id": "children_of:seed/C557EEC878DFD852", + "view_type": "children_of" + }, + "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "8dfdf962988e8d9ba0911ffe32325c75c092d2dd7e1bd09a7c9fbdd1ae6dbee7", + "view_id": "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "children_of" + }, + "counts_by_kind:seed/C557EEC878DFD852": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "17fd876bd4e7b408fe7aa08daf17ff53d3023ea25d21da81996aea407ac84436", + "view_id": "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "counts_by_kind" + }, + "recent_activity:seed/C557EEC878DFD852": { + "data": { + "items": [ + { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "modified_ts": 1700086500, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 3 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "modified_ts": 1700086400, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 2 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "modified_ts": 1700000200, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 1 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "modified_ts": 1700000100, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 0 \u2014 \u00fcn\u00ef" + } + ], + "total_items": 4 + }, + "modified_ts": 1700086500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "2789d66e1f16c659f9edde0c7c3cee89e579640a022ad344a3d50cfc0dfa81e3", + "view_id": "recent_activity:seed/C557EEC878DFD852", + "view_type": "recent_activity" + }, + "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "items": [ + { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "modified_ts": 1700090000, + "subject_id": "7", + "subject_kind": "seed", + "summary": "" + } + ], + "total_items": 1 + }, + "modified_ts": 1700090000, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "58b037aa5a5ef62a6dea6f648603c272b03db3c35e62715865a45f4539043b3a", + "view_id": "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "recent_activity" + } + }, + "checkpoints": { + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "modified_ts": 1700000200, + "rollup": { + "events_by_type": { + "entry.created": 2 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "b555832825c7937caa301eca7b8741f0d68e5f9a494803d114e0761c45de17a4", + "window_end_ts": 1700000200, + "window_key": "2023-11-14", + "window_start_ts": 1700000100, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011", + "event_count": 1, + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000, + "rollup": { + "events_by_type": { + "entry.created": 1 + }, + "subjects": [ + "7" + ], + "subjects_by_kind": { + "seed": 1 + } + }, + "summary_hash": "1fe90410bc94f4f1cc09c11fa39f6f1d76297b26bda81624fd0e68831f72b6fc", + "window_end_ts": 1700090000, + "window_key": "2023-11-15", + "window_start_ts": 1700090000, + "window_type": "day", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500, + "rollup": { + "events_by_type": { + "entry.archived": 1, + "entry.updated": 1 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "f6808d98e759ed5ed4e2ef26755089c69d9113ddc466e5017c656eccf741b5c0", + "window_end_ts": 1700086500, + "window_key": "2023-11-15", + "window_start_ts": 1700086400, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "events": { + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "classification": "internal", + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "integrity_hash": "06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "links": [], + "modified_ts": 1700090000, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "subject_id": "7", + "subject_kind": "seed", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "integrity_hash": "a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "links": [], + "modified_ts": 1700000200, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "7ce12c9b183701d408c08aa95c8a47bab41e9b9bdefe20cd158359b0eff7adfb", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 1 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "integrity_hash": "b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "links": [], + "modified_ts": 1700086400, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 2 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "integrity_hash": "e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "links": [], + "modified_ts": 1700086500, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "5490e096426699e9c417b4f470d4b5ca5175f7805d9e3bac850b736c8dc90186", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 3 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "integrity_hash": "f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "links": [], + "modified_ts": 1700000100, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 0 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:AABBCCDDEEFF0011": { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000 + }, + "writer:profile:C557EEC878DFD852": { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 3, + "event_count": 5, + "last_compaction_ts": 1700090000, + "last_validation_ts": 1700090000, + "writer_count": 2 + }, + "view_manifest": { + "builder_versions": { + "children_of": 1, + "counts_by_kind": 1, + "recent_activity": 1 + }, + "canonical_view_types": [ + "children_of", + "counts_by_kind", + "recent_activity" + ], + "local_only_view_types": [ + "conversation_index", + "hot_nodes", + "semantic_neighbors" + ], + "version": 1 + } + } + }, + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "schema_version": 4 + }, + "context_managed": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "current_fingerprint": "AABBCCDDEEFF0011", + "root_fingerprint": "C557EEC878DFD852", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "context_root": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "current_fingerprint": "C557EEC878DFD852", + "root_fingerprint": "C557EEC878DFD852", + "scope_path": "seed/C557EEC878DFD852", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "description": "index0/atlas events, checkpoints, views and merge", + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "fixture_version": 1, + "manifest_meta": { + "checkpoint_hashes": { + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852": "b555832825c7937caa301eca7b8741f0d68e5f9a494803d114e0761c45de17a4", + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011": "1fe90410bc94f4f1cc09c11fa39f6f1d76297b26bda81624fd0e68831f72b6fc", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852": "f6808d98e759ed5ed4e2ef26755089c69d9113ddc466e5017c656eccf741b5c0" + }, + "checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852" + ], + "schema_version": 1, + "stream_heads": { + "writer:profile:AABBCCDDEEFF0011": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "writer:profile:C557EEC878DFD852": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464" + } + }, + "merged": { + "canonical_views": { + "children_of:seed/C557EEC878DFD852": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "3bf8aa8e904e217efa279005d1d4c7d5e7dc45f922661733655ca3df9b58a59b", + "view_id": "children_of:seed/C557EEC878DFD852", + "view_type": "children_of" + }, + "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "8dfdf962988e8d9ba0911ffe32325c75c092d2dd7e1bd09a7c9fbdd1ae6dbee7", + "view_id": "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "children_of" + }, + "counts_by_kind:seed/C557EEC878DFD852": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "17fd876bd4e7b408fe7aa08daf17ff53d3023ea25d21da81996aea407ac84436", + "view_id": "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "counts_by_kind" + }, + "recent_activity:seed/C557EEC878DFD852": { + "data": { + "items": [ + { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "modified_ts": 1700086500, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 3 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "modified_ts": 1700086400, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 2 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "modified_ts": 1700000200, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 1 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "modified_ts": 1700000100, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 0 \u2014 \u00fcn\u00ef" + } + ], + "total_items": 4 + }, + "modified_ts": 1700086500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "2789d66e1f16c659f9edde0c7c3cee89e579640a022ad344a3d50cfc0dfa81e3", + "view_id": "recent_activity:seed/C557EEC878DFD852", + "view_type": "recent_activity" + }, + "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011": { + "data": { + "items": [ + { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "modified_ts": 1700090000, + "subject_id": "7", + "subject_kind": "seed", + "summary": "" + } + ], + "total_items": 1 + }, + "modified_ts": 1700090000, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "58b037aa5a5ef62a6dea6f648603c272b03db3c35e62715865a45f4539043b3a", + "view_id": "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "recent_activity" + } + }, + "checkpoints": { + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "modified_ts": 1700000200, + "rollup": { + "events_by_type": { + "entry.created": 2 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "b555832825c7937caa301eca7b8741f0d68e5f9a494803d114e0761c45de17a4", + "window_end_ts": 1700000200, + "window_key": "2023-11-14", + "window_start_ts": 1700000100, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011", + "event_count": 1, + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000, + "rollup": { + "events_by_type": { + "entry.created": 1 + }, + "subjects": [ + "7" + ], + "subjects_by_kind": { + "seed": 1 + } + }, + "summary_hash": "1fe90410bc94f4f1cc09c11fa39f6f1d76297b26bda81624fd0e68831f72b6fc", + "window_end_ts": 1700090000, + "window_key": "2023-11-15", + "window_start_ts": 1700090000, + "window_type": "day", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852": { + "checkpoint_id": "cp:day:2023-11-15:writer:profile:C557EEC878DFD852", + "event_count": 2, + "head_hash": "6e20fe85720279fe6f51b12586d4628e7bfde0b72618e93600bce67efda3e464", + "modified_ts": 1700086500, + "rollup": { + "events_by_type": { + "entry.archived": 1, + "entry.updated": 1 + }, + "subjects": [ + "0", + "1" + ], + "subjects_by_kind": { + "password": 1, + "totp": 1 + } + }, + "summary_hash": "f6808d98e759ed5ed4e2ef26755089c69d9113ddc466e5017c656eccf741b5c0", + "window_end_ts": 1700086500, + "window_key": "2023-11-15", + "window_start_ts": 1700086400, + "window_type": "day", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "events": { + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd": { + "actor_id": "AABBCCDDEEFF0011", + "actor_type": "user", + "classification": "internal", + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "integrity_hash": "06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "links": [], + "modified_ts": 1700090000, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "subject_id": "7", + "subject_kind": "seed", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:AABBCCDDEEFF0011" + }, + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "integrity_hash": "a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "links": [], + "modified_ts": 1700000200, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "7ce12c9b183701d408c08aa95c8a47bab41e9b9bdefe20cd158359b0eff7adfb", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 1 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "integrity_hash": "b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "links": [], + "modified_ts": 1700086400, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "ab0c35a0958e2a679778343f5c4639f13c5c14c100a28b99b8d9d9b705812fb3", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 2 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "integrity_hash": "e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "links": [], + "modified_ts": 1700086500, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "5490e096426699e9c417b4f470d4b5ca5175f7805d9e3bac850b736c8dc90186", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "1", + "subject_kind": "totp", + "subject_type": "entry", + "summary": "event 3 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "integrity_hash": "f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "links": [], + "modified_ts": 1700000100, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "0", + "subject_kind": "password", + "subject_type": "entry", + "summary": "event 0 \u2014 \u00fcn\u00ef", + "tags": [ + "auto" + ], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + }, + "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "event_type": "entry.created", + "integrity_hash": "f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "links": [], + "modified_ts": 1700099999, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "99", + "subject_kind": "password", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:AABBCCDDEEFF0011": { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "head_hash": "d903e5a19f229a37b5ea2f7be6967836046761dd111f62457b82957890eab37d", + "modified_ts": 1700090000 + }, + "writer:profile:C557EEC878DFD852": { + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "head_hash": "1ebcaceb046d155dcadcb6ad78c6f1021fbe084d9fafe29d5de96457ebfe1ef4", + "modified_ts": 1700099999 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 3, + "event_count": 6, + "last_compaction_ts": 1700090000, + "last_validation_ts": 1700099999, + "writer_count": 2 + }, + "view_manifest": { + "builder_versions": { + "children_of": 1, + "counts_by_kind": 1, + "recent_activity": 1 + }, + "canonical_view_types": [ + "children_of", + "counts_by_kind", + "recent_activity" + ], + "local_only_view_types": [ + "conversation_index", + "hot_nodes", + "semantic_neighbors" + ], + "version": 1 + } + }, + "one_view": { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + "other": { + "_system": { + "index0": { + "canonical_views": {}, + "checkpoints": {}, + "events": { + "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e": { + "actor_id": "C557EEC878DFD852", + "actor_type": "user", + "classification": "internal", + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "event_type": "entry.created", + "integrity_hash": "f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "links": [], + "modified_ts": 1700099999, + "partition": "standard", + "payload_ref": {}, + "prev_hash": "", + "scope_path": "seed/C557EEC878DFD852", + "subject_id": "99", + "subject_kind": "password", + "subject_type": "entry", + "tags": [], + "visibility": "private", + "writer_id": "writer:profile:C557EEC878DFD852" + } + }, + "heads": { + "writer:profile:C557EEC878DFD852": { + "event_id": "e:f7c6da66b172690419fd5c29afaa998fec1d2331fabc0a6361bc6cba3c55111e", + "head_hash": "1ebcaceb046d155dcadcb6ad78c6f1021fbe084d9fafe29d5de96457ebfe1ef4", + "modified_ts": 1700099999 + } + }, + "schema_version": 1, + "stats": { + "checkpoint_count": 0, + "event_count": 1, + "last_compaction_ts": 0, + "last_validation_ts": 1700099999, + "writer_count": 1 + }, + "view_manifest": { + "builder_versions": {}, + "canonical_view_types": [], + "local_only_view_types": [], + "version": 1 + } + } + }, + "entries": { + "0": { + "kind": "password", + "label": "bank.example", + "links": [ + { + "note": "recovery", + "relation": "depends_on", + "target_id": "2" + } + ], + "modified_ts": 1700000100, + "tags": [ + "money", + "daily" + ] + }, + "1": { + "archived": true, + "kind": "totp", + "label": "email-2fa", + "modified_ts": 1700000200 + }, + "10": { + "kind": "document", + "label": "notes", + "modified_ts": 1700000400 + }, + "2": { + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "modified_ts": 1700000300, + "tags": [ + "ops", + "\u00fcn\u00ef" + ] + }, + "not-a-number": { + "kind": "password", + "label": "weird id", + "modified_ts": 1700000500 + } + }, + "schema_version": 4 + }, + "views": [ + { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "3bf8aa8e904e217efa279005d1d4c7d5e7dc45f922661733655ca3df9b58a59b", + "view_id": "children_of:seed/C557EEC878DFD852", + "view_type": "children_of" + }, + { + "data": { + "children": [ + { + "archived": false, + "entry_id": "0", + "kind": "password", + "label": "bank.example", + "link_count": 1, + "modified_ts": 1700000100, + "tag_count": 2 + }, + { + "archived": true, + "entry_id": "1", + "kind": "totp", + "label": "email-2fa", + "link_count": 0, + "modified_ts": 1700000200, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "2", + "kind": "ssh", + "label": "prod \u2014 \u00fcn\u00efcod\u00e9 \u2713", + "link_count": 0, + "modified_ts": 1700000300, + "tag_count": 2 + }, + { + "archived": false, + "entry_id": "10", + "kind": "document", + "label": "notes", + "link_count": 0, + "modified_ts": 1700000400, + "tag_count": 0 + }, + { + "archived": false, + "entry_id": "not-a-number", + "kind": "password", + "label": "weird id", + "link_count": 0, + "modified_ts": 1700000500, + "tag_count": 0 + } + ], + "total_children": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "8dfdf962988e8d9ba0911ffe32325c75c092d2dd7e1bd09a7c9fbdd1ae6dbee7", + "view_id": "children_of:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "children_of" + }, + { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "582d4144faeee64620eac8307595876fc66dc1e2e20bfb15c375af04866b6523", + "view_id": "counts_by_kind:seed/C557EEC878DFD852", + "view_type": "counts_by_kind" + }, + { + "data": { + "archived_count": 1, + "counts": { + "document": 1, + "password": 2, + "ssh": 1, + "totp": 1 + }, + "total_entries": 5 + }, + "modified_ts": 1700000500, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "17fd876bd4e7b408fe7aa08daf17ff53d3023ea25d21da81996aea407ac84436", + "view_id": "counts_by_kind:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "counts_by_kind" + }, + { + "data": { + "items": [ + { + "event_id": "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "event_type": "entry.archived", + "modified_ts": 1700086500, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 3 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "event_type": "entry.updated", + "modified_ts": 1700086400, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 2 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "event_type": "entry.created", + "modified_ts": 1700000200, + "subject_id": "1", + "subject_kind": "totp", + "summary": "event 1 \u2014 \u00fcn\u00ef" + }, + { + "event_id": "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee", + "event_type": "entry.created", + "modified_ts": 1700000100, + "subject_id": "0", + "subject_kind": "password", + "summary": "event 0 \u2014 \u00fcn\u00ef" + } + ], + "total_items": 4 + }, + "modified_ts": 1700086500, + "scope_path": "seed/C557EEC878DFD852", + "source_checkpoint_ids": [ + "cp:day:2023-11-14:writer:profile:C557EEC878DFD852", + "cp:day:2023-11-15:writer:profile:C557EEC878DFD852" + ], + "source_event_ids": [ + "e:a3ec3134994aefbd548240929188e408be3161d61a26c8803ea62beec44082f4", + "e:b36bbedbce3c6717ed59584beb021a5792fcd7b7640100692770a2de585975a1", + "e:e912fa98f94411b19748226f8561cd6e531da480144b201a00351853bf62a431", + "e:f60e8626b67353147d81db1544b4a2d57cfb478e3fb3822c62648537d266caee" + ], + "view_hash": "2789d66e1f16c659f9edde0c7c3cee89e579640a022ad344a3d50cfc0dfa81e3", + "view_id": "recent_activity:seed/C557EEC878DFD852", + "view_type": "recent_activity" + }, + { + "data": { + "items": [ + { + "event_id": "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd", + "event_type": "entry.created", + "modified_ts": 1700090000, + "subject_id": "7", + "subject_kind": "seed", + "summary": "" + } + ], + "total_items": 1 + }, + "modified_ts": 1700090000, + "scope_path": "seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "source_checkpoint_ids": [ + "cp:day:2023-11-15:writer:profile:AABBCCDDEEFF0011" + ], + "source_event_ids": [ + "e:06e8bb93448e5522e1d2a9d16faa9cb5a476167b3728e770bfc4fdd0edb3e4dd" + ], + "view_hash": "58b037aa5a5ef62a6dea6f648603c272b03db3c35e62715865a45f4539043b3a", + "view_id": "recent_activity:seed/C557EEC878DFD852/managed/AABBCCDDEEFF0011", + "view_type": "recent_activity" + } + ] +} diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index df00c35e..4ec7a46d 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -9,6 +9,7 @@ "entry_secrets.json", "fingerprints.json", "high_risk.json", + "index0.json", "index_keys.json", "kdf_metadata.json", "legacy_payloads.json", @@ -31,6 +32,6 @@ ], "fixture_version": 1, "generator": "scripts/generate_ts_port_fixtures.py", - "python_commit": "885cbe5665498c442ad1d3bebabf5e010db77961", + "python_commit": "64b27d7bf932120392e2b4ff0f46f32de59f26c9", "redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile." } diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index 7507b811..d52167ed 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -11,6 +11,7 @@ import manifestJson from "../fixtures/manifest.json"; import qrJson from "../fixtures/qr.json"; import semanticJson from "../fixtures/semantic.json"; import highRiskJson from "../fixtures/high_risk.json"; +import index0Json from "../fixtures/index0.json"; import bip39Json from "../fixtures/bip39_seeds.json"; import bip85Json from "../fixtures/bip85_entropy.json"; import passwordsV1Json from "../fixtures/passwords_v1.json"; @@ -344,3 +345,17 @@ export const highRiskFixture = highRiskJson as { entries: Record>; partition_file_b64: string; }; + +/** index0/atlas reference state, produced by the Python implementation. */ +export const index0Fixture = index0Json as { + entries: Record>; + context_root: Record; + context_managed: Record; + appended: Record; + compacted: Record; + manifest_meta: Record; + other: Record; + merged: Record; + views: Array>; + one_view: Record; +}; diff --git a/scripts/generate_ts_port_fixtures.py b/scripts/generate_ts_port_fixtures.py index a53d6e9b..f7324d65 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -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