PR #4 needs changes

PR #3 is merged. PR #4 is rebased onto main, but the disk adapter has three reproduced issues.

Changes requested

2 open findings: F57 P1 and F59 P2. F58 verified fixed. No GitHub review comments posted.

+819 −0 total 1ce57ae

Round 2 reviewed exact head 1ce57ae49ac12407a8cfa8e804484fd9997358e8 on 23 September 2026. The incremental fix adds 384 lines and removes 27 across three files. PR #3 remains merged and PR #4 is based on main.

What is fixed and what remains

The eight original failing disk cases now pass. Repair works when a handle opens on an already damaged file, and a valid final record without a newline stays separate from the next append. Reopening after a directory-sync failure also establishes the missing sync. F58 is closed.

F57 remains open because a handle created before another handle tears the file skips repair. Its next append returns success and disappears on the next read. This uses two supported handles sequentially in the same process. F59 remains open because nested journal data is accepted after an object-only check, then recovery assumes fields that were never validated.

Architecture and complexity

The repair offset and shared decoder are reasonable additions. There is no need for a larger persistence framework or multi-process locking to solve the observed failures. The current tail state is an optimization without a reliable invalidation rule across handles. The simplest correctness fix is to inspect the boundary before an append; optimize that only with evidence.

The decoder adds 153 production lines but validates only top-level fields. That leaves a misleading typed boundary. Complete the checks for owned structures such as transactions and intents while leaving arbitrary application result data opaque. Small reusable validators are preferable to another collection of checks tailored to the individual tests.

Verification

pnpm test: 348 pass, comprising 323 evals and 25 journal-file tests. Repository typecheck passes. All 106 prior independent cases pass, including the original 11 disk tests. Six new independent cases produce 5 failures and 1 passing control. The independent test files also typecheck.

K1 reproduces acknowledged append loss after another handle's partial write. K2 has three invalid nested-record cases. K3 confirms the transaction reaches recovery and throws tx.effects is not iterable. K4 passes with two handles opened on an already torn file and verifies that neither successful append is truncated. Filesystem fault injection is not physical power-loss testing.

Current review JSONReview textNew reproductionsNew failure output106 prior checks348 repository testsOriginal review

Round 2 changes

Tail repair and first-open directory syncing were added. A shared journal decoder now checks entry kinds and top-level fields. All original reproductions pass, but constructor-cached tail state and shallow nested decoding leave F57 and F59 open. F58 is verified fixed.

Changed: journal-file/src/index.ts, core/src/types.ts, and a new file-journal-durability.test.ts.

Walkthrough

PR #4 implements the InteractionJournal port with a synchronous JSONL file. It keeps Node filesystem dependencies outside the kernel and relies on the already merged recovery reader.

Files changed
FileChange
packages/journal-file/src/index.ts+155
packages/journal-file/src/file-journal.test.ts+259
packages/journal-file/package.json+24
packages/journal-file/tsconfig.json+8
pnpm-lock.yaml+16
Issues
IDPriorityFinding
F57P1Repair the append boundary after tolerating a torn tail
F58 · fixedP1Do not infer directory durability from file existence
F59P2Separate torn JSON from invalid journal records
Persistence flow

Create/load -> validate JSONL -> append -> complete writes -> fsync file -> sync parent directory -> return. Recovery must also establish a safe append boundary.

Prompt for all review comments · Ctrl+`
Prompt to re-run review · Ctrl+Space

Review comments

P1 F57. Repair the append boundary after tolerating a torn tailpackages/journal-file/src/index.ts:93-96logicdata-integrity
Current fix prompt
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate.

In `packages/journal-file/src/index.ts` around lines 160-165, do not use constructor-time tailRepair absence as proof that the current append boundary is clean. Another supported handle can leave a partial write after this handle was created. Before accepting the next append, inspect the current boundary or enforce equivalent shared invalidation. Preserve prior complete records and the next acknowledged record, or fail closed before writing. K1 in `/tmp/circe-pr4-evidence/disk-round2.test.ts` reproduces the failure using sequential real-file writes and one injected write failure. Keep K4, which verifies that a later handle does not truncate another handle's successful repair, and all earlier disk checks passing.
Round 2 verification: partially fixed

Partially fixed. J1 and J2 now pass when the handle is opened after the torn tail exists. K1 opens two handles on a clean file, injects a partial write and failure through the first, then appends through the surviving handle. Its constructor cached no repair; normalizeTail returns before rereading. The append succeeds but its record disappears on reopen. All operations are sequential in one process. K4 passes when both handles were opened after the tail was already torn.

Current location: packages/journal-file/src/index.ts:160-165

Current fix direction. In `packages/journal-file/src/index.ts` around lines 160-165, do not use constructor-time tailRepair absence as proof that the current append boundary is clean. Another supported handle can leave a partial write after this handle was created. Before accepting the next append, inspect the current boundary or enforce equivalent shared invalidation. Preserve prior complete records and the next acknowledged record, or fail closed before writing. K1 in `/tmp/circe-pr4-evidence/disk-round2.test.ts` reproduces the failure using sequential real-file writes and one injected write failure. Keep K4, which verifies that a later handle does not truncate another handle's successful repair, and all earlier disk checks passing.

Original finding below, preserved from round 1.

The constructor validates the file with parseEntries, but discards the location of the tolerated final fragment. handle then opens the same bytes with O_APPEND. Ignoring a broken tail while reading does not make the file safe to append to.

J1 loads an old valid record followed by an incomplete JSON fragment, then appends a new record successfully. Without a trailing newline, the new record joins the fragment and disappears on the next read. With a trailing newline, the fragment becomes an interior hole and reopening rejects the entire journal. J2 starts with a complete final JSON record missing only its newline; the next append concatenates two JSON objects and both records disappear.

These are supported recovery inputs, not concurrent-writer scenarios. The current tests stop after reading the tolerated tail. A recovery adapter must preserve the next acknowledged append too. Otherwise a successful write-ahead append can be missing when the kernel next reconstructs state.

Fix direction. Retain the last safe byte boundary and normalize the tail before accepting another append, with the repair included in the durability boundary. Alternatively fail closed until an explicit repair completes. Preserve complete records and ensure a final complete record without a newline stays separate from the next record.

J1, J2: three failing cases

93:   parseEntries(path);
94: 
95:   let descriptor: number | undefined;
96:   let directorySynced = existed;
Prompt for AI agents
P1 F58. Do not infer directory durability from file existencepackages/journal-file/src/index.ts:90-96logicdata-integrity
Round 2 verification: fixed

Verified fixed. directorySynced starts false for a new journal object, so reopening after a directory-fsync failure establishes directory durability on the next append. Original J3 passes, along with normal-create and reopen controls.

Current location: packages/journal-file/src/index.ts:133-136

Original finding below, preserved from round 1.

directorySynced is initialized from existsSync. That proves the name is currently visible, not that a previous creation synced its directory. A failed first append can leave a complete, file-synced journal behind before the directory sync succeeds.

J3 injects a failure only into the first parent-directory fsync. The initial append correctly throws and poisons its handle. After close and reopen, a second append returns success, but no parent-directory fsync is attempted because the path now exists. The reopened handle has forgotten the unfinished creation durability step.

File fsync does not establish durability of its directory entry. A later successful append therefore lacks the documented guarantee that the filename survives power loss. This probe establishes the missing syscall after a real-file failure sequence; it does not simulate a physical power loss.

Fix direction. Establish parent-directory durability on the first durable append of a newly opened journal even when its path already exists, or use a protocol that proves creation durability. An existence check alone cannot carry that proof across handles.

J3: one failing case

90:   const existed = existsSync(path);
91:   // Validate before opening: a directory, device or FIFO at this path must not
92:   // become a blocking or redirecting append target.
93:   parseEntries(path);
94: 
95:   let descriptor: number | undefined;
96:   let directorySynced = existed;

File fsync does not guarantee directory-entry persistence; the parent directory needs its own sync. Linux fsync(2).

Prompt for AI agents
P2 F59. Separate torn JSON from invalid journal recordspackages/journal-file/src/index.ts:64-78logicdata-integrity
Current fix prompt
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate.

In `packages/core/src/types.ts` around lines 785-881, complete `decodeJournalEntry` for the owned nested data consumed by recovery. Checking that transaction, intent or result is a non-null object does not establish the InteractionLogEntry type. Reject missing or wrongly typed required nested fields with the journal loader's line-numbered corruption context; keep arbitrary application payloads opaque. K2 and K3 in `/tmp/circe-pr4-evidence/disk-round2.test.ts` cover a transaction without effects, an empty intent, an empty command result and the resulting recovery crash. Reuse compact validators for owned structures instead of adding per-example exceptions or a second divergent schema. Keep valid runtime journal round trips and the original rejection cases passing.
Round 2 verification: partially fixed

Partially fixed. Complete invalid top-level records and unknown kinds now reject, and the original J4/J5 cases pass. The new decoder only checks that nested payloads are objects. K2 admits a transaction with only id, an empty intent and an empty command result. K3 feeds the admitted transaction to recoverState and gets tx.effects is not iterable instead of a line-numbered journal corruption error. These are deliberately malformed files testing the documented decoding boundary, not a claim that normal runtime writes produce them.

Current location: packages/core/src/types.ts:814-818

Current fix direction. In `packages/core/src/types.ts` around lines 785-881, complete `decodeJournalEntry` for the owned nested data consumed by recovery. Checking that transaction, intent or result is a non-null object does not establish the InteractionLogEntry type. Reject missing or wrongly typed required nested fields with the journal loader's line-numbered corruption context; keep arbitrary application payloads opaque. K2 and K3 in `/tmp/circe-pr4-evidence/disk-round2.test.ts` cover a transaction without effects, an empty intent, an empty command result and the resulting recovery crash. Reuse compact validators for owned structures instead of adding per-example exceptions or a second divergent schema. Keep valid runtime journal round trips and the original rejection cases passing.

Original finding below, preserved from round 1.

The catch that tolerates a torn final JSON fragment also catches the loader's own record-shape errors. A complete final null, number or unrelated object is silently discarded. Separately, an object with any string kind is cast to InteractionLogEntry without checking the required data for that entry.

J4 writes a valid record followed by each of null, 42 and an unrelated object, all newline-terminated. All three open successfully instead of reporting corruption. J5 places an effect-frozen object with no effect in the interior of the file; it is also accepted. For the matching interaction, the core recovery reader accesses entry.effect.id directly at journal-recovery.ts:172.

This contradicts the adapter's stated refusal of non-entry lines and moves malformed persisted data past the typed journal boundary. These probes deliberately corrupt files; they do not claim the current runtime normally emits those records. Keep syntax truncation tolerance distinct from record validation, and report a useful line-numbered corruption error.

Fix direction. Catch JSON syntax errors separately from entry decoding. Reject complete invalid records at any position and validate required fields of recognized entry kinds before exposing InteractionLogEntry values. Share a small decoder at the journal boundary if appropriate; avoid maintaining a second divergent journal schema.

J4, J5: four failing cases

64:     try {
65:       const parsed: unknown = JSON.parse(trimmed);
66:       if (
67:         typeof parsed !== "object" ||
68:         parsed === null ||
69:         Array.isArray(parsed) ||
70:         typeof (parsed as { kind?: unknown }).kind !== "string"
71:       ) {
72:         throw new Error("entry is not an object with a string kind");
73:       }
74:       entries.push(parsed as InteractionLogEntry);
75:     } catch (error) {
76:       // A crash mid-append can tear the final line (with or without its
77:       // newline). A damaged interior line is a hole and must be refused.
78:       if (index === lastNonEmpty) continue;
Prompt for AI agents