Skip to content

fix(storage): enforce WAL durability contracts and report unknown write outcomes - #220

Merged
jsam merged 5 commits into
mainfrom
fm/il-wal1
Oct 3, 2026
Merged

jsam merged 5 commits into
mainfrom
fm/il-wal1

Conversation

@jsam

@jsam jsam commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Intent

WAL durability contracts: a write reported failed never reappears after restart, a committed write always survives, a deleted fact never comes back, and an unknown outcome is reported as unknown with the store failing closed.

What Changed

  • WAL commit contract: If a WAL append or fsync fails, the record is now cut back off before the error is returned. If that cut also fails, the target length is saved in a durable current.wal.cut marker (new wal_cut.rs), and the cut is made before the next write or at startup. If the marker can't be saved either, the commit returns a new StorageError::OutcomeUnknown. After that, every write in every knowledge graph is refused with StoreReadOnly until a restart runs recovery. A commit whose record reached the WAL now succeeds even when the flush it triggers fails; the flush failure is logged as persist_flush_failed.
  • Flush and replay: Each shard's metadata has a new flushed_upper frontier, and replay skips revisions below it, so changes already in batch files are not applied twice. The frontier only advances after the batch and metadata renames and the directory fsyncs succeed. sync_directory now returns its errors instead of dropping them, and startup finishes the directory-durability steps before it trusts the frontier. WAL retirement drops only changes that are already durable. Fault-injection tests cover the new paths (durability_tests.rs, wal_tests.rs, program_commit_tests.rs).
  • Protocol, SDKs and docs: New wire error codes outcome_unknown and store_read_only, surfaced by the protocol handler and added to ws-protocol, the AsyncAPI spec and the GUI types. The JS and Python SDKs add OutcomeUnknownError and StoreReadOnlyError, with tests. The persistence, core-concepts, SDK and WebSocket guides describe the commit outcome rules and the read-only state.

Risk Assessment

⚠️ Medium: The latest fix round does what the operator decided for R1 and R2, and I found no new defect in it. The risk stays medium because the branch as a whole changes core WAL, flush and recovery durability paths, where subtle crash-ordering mistakes are costly.

Testing

Built the target server and drove 9 scenario groups live over loopback WebSockets: acknowledge/delete/restart, failed-write rollback and cut marker, repair retries, flush barriers, unknown outcome via wire/Python/JS SDKs, startup barrier failures and restarts, an unreadable ancestor, read-only refusal across every write family, and the KG-drop ACL cleanup outcomes. Faults were injected only into the server processes with an LD_PRELOAD EIO shim, and the server was always stopped with SIGKILL. All groups passed after three harness-only fixes (wrong rule syntax, a revoke with no entry, and the session switching after .kg create); no product code was changed. Supporting checks also passed: targeted Rust durability, WAL, commit and handler tests (61), Python SDK error tests (22) and JS SDK error tests (21). I removed the disposable .wal-live workspace afterwards. There is no UI surface, so no screenshots were taken.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
An acknowledged write survives SIGKILL, and a deleted fact stays deleted across two more restarts ✅ pass live driver.log group basic; acknowledged-and-deleted-*.log
A write whose WAL fsync fails is reported failed and is gone after restart (rollback succeeds, and also when rollback fails and a cut marker is recorded) ✅ pass live driver.log group rollback; failed-write-rollback-.log, failed-write-marker-.log
Repair retries that hit WAL file/dir fsync failures keep refusing writes without resurrecting the failed record; the next good write survives restart ✅ pass live driver.log group repair; repair-file-.log, repair-directory-.log
Flushing a deletion with a batches/shards/wal directory fsync failure never brings the deleted fact back after restarts ✅ pass live driver.log group flush; flush-*.log
When append fsync, rollback and cut marker all fail, the client gets outcome_unknown (wire, Python OutcomeUnknownError, JS OutcomeUnknownError), the next write gets store_read_only, and restart recove… ✅ pass live driver.log group unknown; unknown-wire/python/javascript-*.log
Adversarial: with handler pre-checks removed, every write family (facts, rules, conditional delete, user create/password/role/drop, ACL grant/revoke, KG create/drop) is still refused with store_read_o… ✅ pass live driver.log group readonly_no_prechecks; readonly-families-*.log
Startup fsync failure on the persist-root parent (or root, wal, batches, shards) is fatal; restarting with the root already present re-attempts that fsync (and is refused again), then a clean start ac… ✅ pass live driver.log group startup; startup-data-1.log and startup-data-2.log show FAULT fsync .../data and exit 1 both times
A pre-existing data dir below a non-readable (0311) ancestor still starts and persists commits across restart ✅ pass live driver.log group unreadable_ancestor; unreadable-ancestor-*.log
.kg drop whose ACL cleanup hits a plain WAL failure returns the applied drop result plus a 'not removed' warning row (no error), and the KG stays dropped after restart ✅ pass live kg-drop-driver.log scenario kg-drop-cleanup-failed
Adversarial: .kg drop whose ACL cleanup hits an unknown outcome returns typed outcome_unknown, then later writes get store_read_only, and restart recovers ✅ pass live kg-drop-driver.log scenario kg-drop-cleanup-unknown
Evidence: Live driver transcript (groups 1-8)
{"scenario": "acknowledged-and-deleted", "startup": "ready", "pid": 3197092}
{"scenario": "acknowledged-and-deleted", "program": "+r(1)\n+r(2)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."], ["Inserted 1 fact(s) into 'r'."]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 5, "row_provenance": ["unknown", "unknown"], "errors": []}}
{"scenario": "acknowledged-and-deleted", "stop": "SIGKILL"}
{"scenario": "acknowledged-and-deleted", "startup": "ready", "pid": 3197161}
{"scenario": "acknowledged-and-deleted", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1], [2]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown", "unknown"], "timing_breakdown": {"total_us": 2624, "parse_us": 154, "sip_us": 51, "magic_sets_us": 23, "ir_build_us": 88, "optimize_us": 85, "shared_views_us": 0}, "errors": []}}
{"scenario": "acknowledged-and-deleted", "program": "-r(1)", "response": {"type": "result", "columns": ["message"], "rows": [["Deleted 1 facts from 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "acknowledged-and-deleted", "stop": "SIGKILL"}
{"scenario": "acknowledged-and-deleted", "startup": "ready", "pid": 3197218}
{"scenario": "acknowledged-and-deleted", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[2]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 2469, "parse_us": 158, "sip_us": 51, "magic_sets_us": 23, "ir_build_us": 60, "optimize_us": 79, "shared_views_us": 0}, "errors": []}}
{"scenario": "acknowledged-and-deleted", "stop": "SIGKILL"}
{"scenario": "acknowledged-and-deleted", "startup": "ready", "pid": 3197311}
{"scenario": "acknowledged-and-deleted", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[2]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 2468, "parse_us": 163, "sip_us": 52, "magic_sets_us": 24, "ir_build_us": 100, "optimize_us": 80, "shared_views_us": 0}, "errors": []}}
{"scenario": "acknowledged-and-deleted", "stop": "SIGKILL"}
{"group": "basic", "result": "pass"}
{"scenario": "failed-write-rollback", "startup": "ready", "pid": 3197458}
{"scenario": "failed-write-rollback", "program": "+r(1)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 4, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "failed-write-rollback", "program": "+r(2)", "response": {"type": "error", "message": "I/O error: Input/output error (os error 5)", "code": "internal"}}
{"scenario": "failed-write-rollback", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 2165, "parse_us": 138, "sip_us": 73, "magic_sets_us": 24, "ir_build_us": 53, "optimize_us": 69, "shared_views_us": 0}, "errors": []}}
{"scenario": "failed-write-rollback", "stop": "SIGKILL"}
{"scenario": "failed-write-rollback", "startup": "ready", "pid": 3197535}
{"scenario": "failed-write-rollback", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 2658, "parse_us": 151, "sip_us": 57, "magic_sets_us": 23, "ir_build_us": 60, "optimize_us": 67, "shared_views_us": 0}, "errors": []}}
{"scenario": "failed-write-rollback", "program": "+r(3)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "failed-write-rollback", "stop": "SIGKILL"}
{"scenario": "failed-write-rollback", "startup": "ready", "pid": 3197587}
{"scenario": "failed-write-rollback", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1], [3]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown", "unknown"], "timing_breakdown": {"total_us": 2716, "parse_us": 144, "sip_us": 57, "magic_sets_us": 39, "ir_build_us": 64, "optimize_us": 69, "shared_views_us": 0}, "errors": []}}
{"scenario": "failed-write-rollback", "stop": "SIGKILL"}
{"scenario": "failed-write-marker", "startup": "ready", "pid": 3197683}
{"scenario": "failed-write-marker", "program": "+r(1)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 5, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "failed-write-marker", "program": "+r(2)", "response": {"type": "error", "message": "I/O error: Input/output error (os error 5)", "code": "internal"}}
{"scenario": "failed-write-marker", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 2290, "parse_us": 146, "sip_us": 51, "magic_sets_us": 23, "ir_build_us": 44, "optimize_us": 73, "shared_views_us": 0}, "errors": []}}
{"scenario": "failed-write-marker", "stop": "SIGKILL"}
{"scenario": "failed-write-marker", "startup": "ready", "pid": 3197984}
{"scenario": "failed-write-marker", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 2458, "parse_us": 155, "sip_us": 57, "magic_sets_us": 51, "ir_build_us": 75, "optimize_us": 71, "shared_views_us": 0}, "errors": []}}
{"scenario": "failed-write-marker", "program": "+r(3)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "failed-write-marker", "stop": "SIGKILL"}
{"scenario": "failed-write-marker", "startup": "ready", "pid": 3198752}
{"scenario": "failed-write-marker", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[3], [1]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown", "unknown"], "timing_breakdown": {"total_us": 2487, "parse_us": 145, "sip_us": 52, "magic_sets_us": 52, "ir_build_us": 62, "optimize_us": 70, "shared_views_us": 0}, "errors": []}}
{"scenario": "failed-write-marker", "stop": "SIGKILL"}
{"group": "rollback", "result": "pass"}
{"scenario": "repair-file", "startup": "ready", "pid": 3199066}
{"scenario": "repair-file", "program": "+r(1)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 9, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "repair-file", "program": "+r(2)", "response": {"type": "error", "message": "I/O error: Input/output error (os error 5)", "code": "internal"}}
{"scenario": "repair-file", "program": "+r(3)", "response": {"type": "error", "message": "I/O error: Input/output error (os error 5)", "code": "internal"}}
{"scenario": "repair-file", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 2284, "parse_us": 138, "sip_us": 49, "magic_sets_us": 23, "ir_build_us": 50, "optimize_us": 79, "shared_views_us": 0}, "errors": []}}
{"scenario": "repair-file", "program": "+r(3)", "response

... [23096 bytes truncated] ...

8, "ir_build_us": 70, "optimize_us": 99, "shared_views_us": 1}, "errors": []}}
{"scenario": "unreadable-ancestor", "stop": "SIGKILL"}
{"group": "unreadable_ancestor", "result": "pass"}
{"scenario": "readonly-families", "startup": "ready", "pid": 3208261}
{"scenario": "readonly-families", "program": "+r(1)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 6, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "readonly-families", "program": ".kg create g2", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge graph 'g2' created."], ["Switched to knowledge graph: g2"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 4, "row_provenance": ["unknown", "unknown"], "switched_kg": "g2", "errors": []}}
{"scenario": "readonly-families", "program": ".user create carol password123 viewer", "response": {"type": "result", "columns": ["message"], "rows": [["User 'carol' created with role 'viewer'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "readonly-families", "program": ".kg acl grant g2 carol viewer", "response": {"type": "result", "columns": ["message"], "rows": [["Granted 'viewer' access on 'g2' to 'carol'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "readonly-families", "program": "+r(2)", "response": {"type": "error", "message": "write outcome unknown, store read-only until restart recovery: write I/O error: Input/output error (os error 5); undo Input/output error (os error 5)", "code": "outcome_unknown"}}
{"scenario": "readonly-families", "program": "+r(3)", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".user create dave password123 viewer", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".user password carol newpassword123", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".user role carol editor", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".user drop carol", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".kg acl grant g2 carol editor", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".kg acl revoke g2 carol", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".kg create g3", "response": {"type": "error", "message": "Create failed: store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": ".kg drop g2", "response": {"type": "error", "message": "Drop failed: store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": "+q(X) <- r(X)", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": "-r(X) <- r(X)", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "readonly-families", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "timing_breakdown": {"total_us": 1933, "parse_us": 81, "sip_us": 45, "magic_sets_us": 23, "ir_build_us": 23, "optimize_us": 29, "shared_views_us": 0}, "errors": []}}
{"scenario": "readonly-families", "stop": "SIGKILL"}
{"scenario": "readonly-families", "startup": "ready", "pid": 3212598}
{"scenario": "readonly-families", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[1], [2]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown", "unknown"], "timing_breakdown": {"total_us": 2485, "parse_us": 152, "sip_us": 52, "magic_sets_us": 24, "ir_build_us": 65, "optimize_us": 69, "shared_views_us": 0}, "errors": []}}
{"scenario": "readonly-families", "after_restart_rows": [[1], [2]]}
{"scenario": "readonly-families", "program": "+r(4)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 2, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "readonly-families", "stop": "SIGKILL"}
{"scenario": "readonly-families", "startup": "ready", "pid": 3214278}
{"scenario": "readonly-families", "program": "?r(X)", "response": {"type": "result", "columns": ["X"], "rows": [[4], [1], [2]], "row_count": 3, "total_count": 3, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown", "unknown", "unknown"], "timing_breakdown": {"total_us": 2469, "parse_us": 174, "sip_us": 58, "magic_sets_us": 23, "ir_build_us": 67, "optimize_us": 94, "shared_views_us": 1}, "errors": []}}
{"scenario": "readonly-families", "stop": "SIGKILL"}
{"group": "readonly_no_prechecks", "result": "pass"}
{"scenario": "kg-drop-cleanup-failed", "startup": "ready", "pid": 3215436}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg create g", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge graph 'g' created."], ["Switched to knowledge graph: g"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 4, "row_provenance": ["unknown", "unknown"], "switched_kg": "g", "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg acl grant g bob viewer", "response": {"type": "result", "columns": ["message"], "rows": [["Granted 'viewer' access on 'g' to 'bob'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg drop g", "response": {"type": "error", "message": "Cannot drop current knowledge graph. Switch to another first.", "code": "conflict"}}
{"scenario": "kg-drop-cleanup-failed", "stop": "SIGKILL"}
Traceback (most recent call last):
  File "~/.no-mistakes/evidence/01M41R4738J4AQYJYSAD7GNEX7/run-7836a01/live2.py", line 151, in <module>
    if __name__=='__main__':asyncio.run(main())
                            ^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/asyncio/runners.py", line 194, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/asyncio/base_events.py", line 687, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "~/.no-mistakes/evidence/01M41R4738J4AQYJYSAD7GNEX7/run-7836a01/live2.py", line 148, in main
    await fn();record(group=fn.__name__,result='pass')
    ^^^^^^^^^^
  File "~/.no-mistakes/evidence/01M41R4738J4AQYJYSAD7GNEX7/run-7836a01/live2.py", line 132, in kg_drop_cleanup
    s.arm(('fsync','/current.wal',0,1));msg=await s.run(w,'.kg drop g');s.consumed()
                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/evidence/01M41R4738J4AQYJYSAD7GNEX7/run-7836a01/live2.py", line 41, in run
    else: assert msg['type']=='result' and not errors,msg
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: {'type': 'error', 'message': 'Cannot drop current knowledge graph. Switch to another first.', 'code': 'conflict'}
  • Evidence: Live protocol transcript JSONL (local file: ~/.no-mistakes/evidence/01M41R4738J4AQYJYSAD7GNEX7/run-7836a01/live-transcript.jsonl)
Evidence: KG drop cleanup live transcript
{"scenario": "kg-drop-cleanup-failed", "startup": "ready", "pid": 3220883}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg create g", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge graph 'g' created."], ["Switched to knowledge graph: g"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown", "unknown"], "switched_kg": "g", "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg use default", "response": {"type": "result", "columns": ["message"], "rows": [["Switched to knowledge graph: default"]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown"], "switched_kg": "default", "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg acl grant g bob viewer", "response": {"type": "result", "columns": ["message"], "rows": [["Granted 'viewer' access on 'g' to 'bob'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg drop g", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge graph 'g' dropped."], ["Access entries for 'g' were not removed: I/O error: Input/output error (os error 5)"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 26, "row_provenance": ["unknown", "unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg list", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge Graphs:"], ["  default *"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown", "unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "stop": "SIGKILL"}
{"scenario": "kg-drop-cleanup-failed", "startup": "ready", "pid": 3221091}
{"scenario": "kg-drop-cleanup-failed", "program": ".kg list", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge Graphs:"], ["  default *"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown", "unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "kg_list_after_restart": [["Knowledge Graphs:"], ["  default *"]]}
{"scenario": "kg-drop-cleanup-failed", "program": "+r(1)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 5, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-failed", "stop": "SIGKILL"}
{"scenario": "kg-drop-cleanup-unknown", "startup": "ready", "pid": 3221182}
{"scenario": "kg-drop-cleanup-unknown", "program": ".kg create g", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge graph 'g' created."], ["Switched to knowledge graph: g"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 3, "row_provenance": ["unknown", "unknown"], "switched_kg": "g", "errors": []}}
{"scenario": "kg-drop-cleanup-unknown", "program": ".kg use default", "response": {"type": "result", "columns": ["message"], "rows": [["Switched to knowledge graph: default"]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown"], "switched_kg": "default", "errors": []}}
{"scenario": "kg-drop-cleanup-unknown", "program": ".kg acl grant g bob viewer", "response": {"type": "result", "columns": ["message"], "rows": [["Granted 'viewer' access on 'g' to 'bob'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-unknown", "program": ".kg drop g", "response": {"type": "error", "message": "write outcome unknown, store read-only until restart recovery: write I/O error: Input/output error (os error 5); undo Input/output error (os error 5)", "code": "outcome_unknown"}}
{"scenario": "kg-drop-cleanup-unknown", "program": "+r(1)", "response": {"type": "error", "message": "store read-only until restart recovery", "code": "store_read_only"}}
{"scenario": "kg-drop-cleanup-unknown", "stop": "SIGKILL"}
{"scenario": "kg-drop-cleanup-unknown", "startup": "ready", "pid": 3221319}
{"scenario": "kg-drop-cleanup-unknown", "program": ".kg list", "response": {"type": "result", "columns": ["message"], "rows": [["Knowledge Graphs:"], ["  default *"]], "row_count": 2, "total_count": 2, "truncated": false, "execution_time_ms": 0, "row_provenance": ["unknown", "unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-unknown", "kg_list_after_restart": [["Knowledge Graphs:"], ["  default *"]]}
{"scenario": "kg-drop-cleanup-unknown", "program": "+r(1)", "response": {"type": "result", "columns": ["message"], "rows": [["Inserted 1 fact(s) into 'r'."]], "row_count": 1, "total_count": 1, "truncated": false, "execution_time_ms": 6, "row_provenance": ["unknown"], "errors": []}}
{"scenario": "kg-drop-cleanup-unknown", "stop": "SIGKILL"}
{"group": "kg_drop_cleanup", "result": "pass"}
Evidence: Startup refused on persist-root parent fsync EIO (first start)
InputLayer Server
=================

^[[2m2026-10-03T22:49:11.641203Z^[[0m ^[[32m INFO^[[0m ^[[2minputlayer::storage_engine^[[0m^[[2m:^[[0m data_dir_lock_acquired ^[[3mdata_dir^[[0m^[[2m=^[[0m/home/sam/.no-mistakes/worktrees/9eea230c3aae/01M41R4738J4AQYJYSAD7GNEX7/.wal-live/startup-data/data ^[[3melapsed_us^[[0m^[[2m=^[[0m239
FAULT fsync ~/.no-mistakes/worktrees/9eea230c3aae/01M41R4738J4AQYJYSAD7GNEX7/.wal-live/startup-data/data EIO
ERROR: Failed to initialize InputLayer: Failed to create storage: I/O error: Input/output error (os error 5)
Error: "Failed to create storage: I/O error: Input/output error (os error 5)"
Evidence: Restart with existing root still fsyncs parent and is refused
InputLayer Server
=================

^[[2m2026-10-03T22:49:11.691874Z^[[0m ^[[32m INFO^[[0m ^[[2minputlayer::storage_engine^[[0m^[[2m:^[[0m data_dir_lock_acquired ^[[3mdata_dir^[[0m^[[2m=^[[0m/home/sam/.no-mistakes/worktrees/9eea230c3aae/01M41R4738J4AQYJYSAD7GNEX7/.wal-live/startup-data/data ^[[3melapsed_us^[[0m^[[2m=^[[0m194
FAULT fsync ~/.no-mistakes/worktrees/9eea230c3aae/01M41R4738J4AQYJYSAD7GNEX7/.wal-live/startup-data/data EIO
ERROR: Failed to initialize InputLayer: Failed to create storage: I/O error: Input/output error (os error 5)
Error: "Failed to create storage: I/O error: Input/output error (os error 5)"
Evidence: KG drop result kept after ACL cleanup failure
.kg drop g -> result rows: ["Knowledge graph 'g' dropped.", "Access entries for 'g' were not removed: I/O error: Input/output error (os error 5)"], errors: []; .kg list after SIGKILL restart: default only
Evidence: Unknown outcome then fail-closed
+r(2) -> {code: outcome_unknown, message: 'write outcome unknown, store read-only until restart recovery: ...'}; then +r(3), .user create/password/role/drop, .kg acl grant/revoke, .kg create/drop, +q(X) <- r(X), -r(X) <- r(X) -> all {code: store_read_only}; Python SDK: OutcomeUnknownError then StoreReadOnlyError; JS SDK: OutcomeUnknownError then StoreReadOnlyError
Evidence: Live harness, fault injector and methodology
# Live WAL validation at 7836a016

Built `target/debug/inputlayer-server` from the target commit (unmodified) and ran it with isolated configs/data dirs under a disposable `.wal-live/` (removed afterwards). Clients: raw loopback WebSocket, the Python SDK `Connection`, and the JS SDK `Connection` (bundled from packages/inputlayer-js/src/connection.ts with the local esbuild). `fault.c` is an LD_PRELOAD shim, loaded only into those server processes, that returns EIO from fsync/fdatasync/ftruncate on paths matching a control-file suffix. Every armed fault is asserted consumed. Server shutdowns were SIGKILL.

Driver: live2.py (groups basic, rollback, repair, flush, unknown, startup, unreadable_ancestor, readonly_no_prechecks, kg_drop_cleanup). Transcripts: live-transcript.jsonl/driver.log (groups 1-8), kg-drop-transcript.jsonl/kg-drop-driver.log (group 9, rerun after a harness fix). Per-server logs: <scenario>-<n>.log.

Harness-only corrections made during the run: `.kg acl revoke` needs an existing entry, `.rule add` is not IQL (persistent rules are `+q(X) <- r(X)`), `.kg create` switches the session so the drop test does `.kg use default` first, and per-scenario dirs are wiped before each run.
Evidence: Targeted Rust test results
test storage::persist::wal::tests::cut_never_extends_a_shorter_file ... ok
test storage::persist::wal::tests::durability_repair_retries_sync_even_when_length_matches ... ok
test storage::persist::wal::tests::cleanup_removes_stale_files_but_keeps_quarantined_bytes ... ok
test storage::persist::wal::tests::append_then_read_in_commit_order ... ok
test storage::persist::wal::tests::buffered_append_is_durable_after_sync ... ok
test storage::persist::wal::tests::failed_write_keeps_earlier_buffered_records ... ok
test storage::persist::wal::tests::durable_append_survives_without_drop ... ok
test storage::persist::wal::tests::clear_then_append ... ok
test storage::persist::wal::tests::intact_record_of_unknown_format_fails_open ... ok
test storage::persist::wal::tests::failed_sync_is_cut_back_and_never_recovered ... ok
test storage::persist::wal::tests::failed_cut_back_is_recorded_and_made_at_the_next_open ... ok
test storage::persist::wal::tests::sync_with_no_writer_is_a_no_op ... ok
test storage::persist::wal::tests::failed_write_is_cut_back_and_never_recovered ... ok
test storage::persist::wal::tests::failed_cut_back_is_repaired_before_a_rewrite_reads_the_file ... ok
test storage::persist::wal::tests::remove_last_shard_removes_the_file ... ok
test storage::persist::wal::tests::failed_flush_repairs_to_the_intact_prefix_before_the_next_write ... ok
test storage::persist::wal::tests::damage_before_the_end_recovers_the_prefix_and_quarantines_the_rest ... ok
test storage::persist::wal::tests::cut_marker_is_removed_before_the_next_record_is_appended ... ok
test storage::persist::wal::tests::failed_cut_back_is_repaired_to_the_exact_length_before_the_next_write ... ok
test storage::persist::wal::tests::remove_unknown_shard_leaves_the_file_alone ... ok
test storage::persist::wal::tests::record_without_its_newline_is_not_committed ... ok
test storage::persist::wal::tests::open_cuts_a_torn_tail_and_appends_after_it ... ok
test storage::persist::wal::tests::unreadable_cut_marker_fails_open_and_keeps_the_file ... ok
test storage::persist::wal::tests::unrecordable_cut_back_reports_an_unknown_outcome_and_blocks_writes ... ok
test storage::persist::wal::tests::remove_shard_keeps_other_shards_of_the_same_transaction ... ok
test storage_engine::program_commit_tests::arity_of_a_new_relation_is_set_by_its_first_staged_insert ... ok
test storage::persist::durability_tests::replay_retires_records_already_in_batches ... ok
test storage_engine::program_commit_tests::cancelled_program_writes_nothing ... ok
test storage::persist::durability_tests::enospc_on_flush_after_commit_keeps_the_commit ... ok
test storage::persist::durability_tests::durability_existing_root_opens_under_unreadable_ancestor ... ok
test storage::persist::durability_tests::durability_restart_after_failed_parent_sync_syncs_it_again ... ok
test storage::persist::durability_tests::flush_retried_after_enospc_keeps_every_commit_once ... ok
test storage_engine::program_commit_tests::passed_deadline_writes_nothing ... ok
test storage::persist::wal::tests::durability_repair_retries_sync_absent_marker_live_and_on_open ... ok
test storage_engine::program_commit_tests::blind_programs_never_go_stale ... ok
test storage_engine::program_commit_tests::a_stop_after_the_commit_began_is_too_late ... ok
test protocol::handler::tests::durability_protocol_preserves_unknown_and_read_only ... ok
test storage::persist::durability_tests::unrecordable_cut_back_reports_an_unknown_outcome ... ok
test storage_engine::program_commit_tests::duplicates_and_changes_that_cancel_out_write_nothing ... ok
test storage::persist::durability_tests::failed_metadata_save_after_commit_keeps_the_commit_readable ... ok
test storage::persist::durability_tests::revision_already_flushed_is_rejected ... ok
test storage::persist::durability_tests::failed_wal_retirement_never_resurrects_a_deleted_fact ... ok
test storage_engine::program_commit_tests::stale_read_is_refused_and_a_fresh_one_commits ... ok
test storage::persist::durability_tests::failed_cut_back_then_crash_never_replays_the_failed_commit ... ok
test storage_engine::program_commit_tests::writes_to_unread_relations_do_not_conflict ... ok
test storage_engine::program_commit_tests::rule_change_makes_a_read_stale ... ok
test storage_engine::program_commit_tests::late_arity_error_rejects_the_whole_program ... ok
test storage_engine::program_commit_tests::durability_unknown_commit_blocks_every_later_commit_until_restart ... ok
test storage_engine::program_commit_tests::delete_with_invalid_replacement_keeps_the_old_fact ... ok
test storage::persist::durability_tests::durability_compaction_failure_leaves_memory_readable ... ok
test storage_engine::program_commit_tests::durability_deleted_relation_keeps_tombstone_after_unlink_sync_failure ... ok
test storage::persist::durability_tests::durability_startup_barriers_precede_wal_retirement_and_orphan_cleanup ... ok
test storage_engine::program_commit_tests::program_commits_one_record_across_relations ... ok
test storage_engine::program_commit_tests::persistence_failure_rejects_the_whole_program_now_and_after_restart ... ok
test storage::persist::durability_tests::durability_directory_creation_retries_parent_barriers ... ok
test storage::persist::durability_tests::durability_directory_failure_preserves_flush_memory_and_wal ... ok
test storage_engine::program_commit_tests::readers_never_observe_part_of_a_program ... ok
test storage_engine::program_commit_tests::durability_wal_retirement_failure_keeps_deletion_committed ... ok
test protocol::handler::tests::user_drop_cleanup_preserves_unknown_outcome ... ok
test protocol::handler::tests::kg_drop_keeps_applied_result_when_acl_cleanup_fails ... ok
test protocol::handler::tests::user_drop_cleanup_continues_after_transient_failure ... ok
test result: ok. 61 passed; 0 failed; 0 ignored; 0 measured; 2895 filtered out; finished in 4.01s

Pipeline

Updates from git push no-mistakes

... (5 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

🔧 **Review** - 3 issues found → auto-fixed (5) ✅

🔧 Fix applied.
3 issues (1 error, 2 warnings) still open:

  • ⚠️ src/protocol/handler.rs:4255 - Round 1 introduces an error response that loses an already-applied graph switch. An editor executes .kg create new; the server switches the session, then the automatic owner-ACL write returns OutcomeUnknown. This hunk adds a statement error, but settle_result converts the single-statement result into ProgramError and discards switched_kg. Both SDKs retain the old graph in their cache, so subsequent reads intended for it go to the new graph and fail authorization. Preserve the complete result when an error follows a graph switch. Related sites: handler.rs:4242 (session switch), :5386 (result discarded); JS connection.ts:275 and Python connection.py:278 (switch tracking requires the result field).
  • 🚨 src/protocol/handler.rs:1365 - Round 1 introduced an ordinary-error early exit that skips security cleanup. Drop a user who has API keys and access to a private graph: the user deletion succeeds, then API-key deletion encounters a transient WAL error whose rollback succeeds. This ? skips ACL removal. Recreating that username now inherits the old graph access because authorization matches ACLs by username. Related sites: src/protocol/handler.rs:1348 deletes the user, :1382 performs the skipped ACL cleanup, :1304 permits recreation, and :1705 matches the retained ACL. This exceeds the required unknown-outcome propagation. Revert the fail-fast expansion to the minimal fix: preserve typed OutcomeUnknown/StoreReadOnly handling, but remove the ordinary-error early exit so remaining ACL cleanup still runs.
  • ⚠️ src/protocol/error.rs:8 - Simplification: Round 1 also extends the unused InputLayerError protocol enum. Repository callers use ProgramError and the WebSocket ErrorCode; InputLayerError has no production consumers beyond its re-export. No stated requirement needs this parallel outcome representation. Remove the added variants here and their conversion arms at src/protocol/error.rs:55–56, retaining the active WebSocket implementation.

🔧 Fix applied.
1 warning still open:

  • ⚠️ src/protocol/error.rs:8 - Simplification: Round 1 also extends the unused InputLayerError protocol enum. Repository callers use ProgramError and the WebSocket ErrorCode; InputLayerError has no production consumers beyond its re-export. No stated requirement needs this parallel outcome representation. Remove the added variants here and their conversion arms at src/protocol/error.rs:55–56, retaining the active WebSocket implementation.

  • ⚠️ src/protocol/handler.rs:4122 - An applied KG drop can now be reported as an ordinary failure. In this change, cleanup_kg_acls was changed from best-effort to -&gt; Result and is called with ? after the drop has already committed and its sessions have been closed. Example: .kg drop g succeeds, then the ACL delete hits a transient WAL error whose rollback succeeds. The request returns ProgramError{code: Internal} and the result is thrown away. The client is told the drop failed even though it took effect, and a retry gets NotFound. The typed outcome is only needed for OutcomeUnknown and StoreReadOnly. Smallest fix: propagate only those two codes. For any other cleanup error, keep the result and attach a StatementError, the same way the new owner-grant path does at handler.rs:4104-4111.

  • ⚠️ packages/inputlayer-js/tests/statement-errors.test.ts:246 - An import insertion went into the wrong place and corrupted an existing assertion. OutcomeUnknownError, and StoreReadOnlyError, were pasted into the argument list of rejects.toBeInstanceOf(StatementFailedError, ...) in the '.kg use elsewhere / .rel drop x' test. toBeInstanceOf takes one expected class, so the extra arguments are ignored at runtime and fail a TypeScript type-check. Remove lines 246-247. The imports themselves are already correct at lines 20-21.

  • ⚠️ src/storage/persist/mod.rs:1173 - create_directory now fsyncs every ancestor of the persist path up to / on every startup (it is also called again for wal/ from PersistWal::open), and any open or fsync error is fatal. Before this change, directory sync was best-effort. Durable creation only needs the parents of directories this process may have created. If any higher ancestor cannot be opened for reading (for example an execute-only 0711 directory above the data dir) or does not support directory fsync, the server now refuses to start where it used to start. Required intent (review-2) only asks to fsync the parents of the newly created persist root, its subdirectories, and wal/. A narrower option: stop at the first ancestor that existed before this process ran, or tolerate EACCES/EINVAL above the configured root's parent. This is a deployment-policy choice, so it needs confirmation.

  • ⚠️ src/protocol/handler.rs:1348 - Simplification: the change adds a second copy of the read-only latch check at the start of each handler. Every write these handlers make already goes through StorageEngine and FilePersist::commit, and those reject with StoreReadOnly at the shared commit boundary (persist/mod.rs:602/616, program_commit.rs:48, storage_engine mod.rs:1364). That shared boundary already meets the fail-closed requirement, so the handler-level pre-checks are not needed. Sites: handler.rs:1348 (user create), 1396 (user drop), 1478 (password), 1531 (role), 1672 (ACL grant), 1748 (ACL revoke), 4423 (ontology install), 4677 (ontology remove), 4814 (ontology upgrade). Recommend removing them and keeping the storage-level checks, including the ones that guard non-WAL file writes (create_knowledge_graph, drop_relation_in, prepare_drop_knowledge_graph).

  • ℹ️ packages/inputlayer-js/src/errors.ts:52 - The SDK docs for OutcomeUnknownError say every outcome_unknown means "store read-only until restart recovery". The server also sends outcome_unknown for two cases that do not latch read-only: CommitError::Unknown (write_run.rs:357) and a panic during commit (supervise.rs:168). In those cases writes still succeed, so the doc misleads callers. The same wording is in packages/inputlayer-py/src/inputlayer/exceptions.py:93. Suggest wording such as "the store may be read-only until restart; a following write raises StoreReadOnlyError if so".

🔧 Fix applied.
5 warnings still open:

  • ⚠️ src/protocol/handler.rs:4122 - An applied KG drop can now be reported as an ordinary failure. In this change, cleanup_kg_acls was changed from best-effort to -&gt; Result and is called with ? after the drop has already committed and its sessions have been closed. Example: .kg drop g succeeds, then the ACL delete hits a transient WAL error whose rollback succeeds. The request returns ProgramError{code: Internal} and the result is thrown away. The client is told the drop failed even though it took effect, and a retry gets NotFound. The typed outcome is only needed for OutcomeUnknown and StoreReadOnly. Smallest fix: propagate only those two codes. For any other cleanup error, keep the result and attach a StatementError, the same way the new owner-grant path does at handler.rs:4104-4111.
  • ⚠️ src/storage/persist/mod.rs:1173 - create_directory now fsyncs every ancestor of the persist path up to / on every startup (it is also called again for wal/ from PersistWal::open), and any open or fsync error is fatal. Before this change, directory sync was best-effort. Durable creation only needs the parents of directories this process may have created. If any higher ancestor cannot be opened for reading (for example an execute-only 0711 directory above the data dir) or does not support directory fsync, the server now refuses to start where it used to start. Required intent (review-2) only asks to fsync the parents of the newly created persist root, its subdirectories, and wal/. A narrower option: stop at the first ancestor that existed before this process ran, or tolerate EACCES/EINVAL above the configured root's parent. This is a deployment-policy choice, so it needs confirmation.
  • ⚠️ src/protocol/handler.rs:1348 - Simplification: the change adds a second copy of the read-only latch check at the start of each handler. Every write these handlers make already goes through StorageEngine and FilePersist::commit, and those reject with StoreReadOnly at the shared commit boundary (persist/mod.rs:602/616, program_commit.rs:48, storage_engine mod.rs:1364). That shared boundary already meets the fail-closed requirement, so the handler-level pre-checks are not needed. Sites: handler.rs:1348 (user create), 1396 (user drop), 1478 (password), 1531 (role), 1672 (ACL grant), 1748 (ACL revoke), 4423 (ontology install), 4677 (ontology remove), 4814 (ontology upgrade). Recommend removing them and keeping the storage-level checks, including the ones that guard non-WAL file writes (create_knowledge_graph, drop_relation_in, prepare_drop_knowledge_graph).
  • ⚠️ src/protocol/handler.rs:4117 - The round-5 F1 fix doesn't change what the client sees, so the problem it was meant to fix is still there. '.kg create' and '.kg drop' must be sent as a single statement (handler.rs:3849-3854), and a drop leaves switched_kg as None. That means settle_result (handler.rs:5280) always turns the StatementError pushed here into Err(ProgramError{code: Internal}) and throws the result away. Example: .kg drop g commits, then the kg_acls delete hits a transient WAL error whose rollback succeeds. The client is still told the drop failed, and a retry gets NotFound, exactly as F1 described. The owner-grant path only keeps its result because switched_kg is Some there; that precondition never holds for a drop. No test covers this path. Following the recorded instruction literally ('attach a StatementError like the owner-grant path') cannot achieve its stated goal ('keep the drop result'), so the remedy needs a decision. Option 1: report the non-typed cleanup failure without making the response an error, for example as a warning message on the Ok result. Option 2: let settle_result keep a single-statement result whose primary effect was applied. Keep OutcomeUnknown and StoreReadOnly as typed errors either way.
  • ⚠️ src/storage/persist/mod.rs:1175 - In round 5 (F3), create_directory now syncs only the parents of directories that did not exist before the call. Concrete sequence: on first start, create_dir_all creates <parent>/persist, then the fsync of <parent> fails and FilePersist::new returns an error. The operator restarts. The root now exists, so created is empty, the parent barrier is skipped, and FilePersist::new succeeds without ever making the root's directory entry durable. A commit is acknowledged; after a power loss the whole persist directory, including the WAL holding that commit, can vanish. That breaks the intent 'a committed write always survives'. It also contradicts the earlier recorded review-2 decision to retry the barrier when the directories already exist. The same round weakened the tests to match: durability_tests.rs:290 now makes only one failing attempt for 'ancestor', and the standalone PersistWal retry loop at durability_tests.rs:304 was cut to one attempt. For wal/, the retry gap is covered by the root fsync at mod.rs:176; the gap above the root is not. The later F3 decision ('only directories this process actually created') supersedes the earlier one, so whether this is acceptable is a policy decision. One option that still passes the unreadable-ancestor test: always sync config.path's immediate parent when the earlier attempt could have created the root, for example by tolerating only EACCES/EPERM on that sync. The other is to accept the gap and document it.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Commit inserts and delete a fact; acknowledged data survives repeated abrupt restarts without resurrecting the deletion. ✅ pass live Live protocol and SDK transcripts: acknowledged-and-deleted
Encounter append fsync failure, including failed rollback; rejected writes remain absent after cut-marker recovery and subsequent commits. ✅ pass live Live protocol and SDK transcripts: failed-write-rollback, failed-write-marker
Retry repair after file or directory fsync failures; writes and startup remain blocked until repair durability succeeds. ✅ pass live Live protocol and SDK transcripts: repair-file, repair-directory, recovery-repair
Commit deletion while flush durability or WAL retirement fails; deletion survives restart even when the unsynced batch is discarded. ✅ pass live Live protocol and SDK transcripts: flush-batches, flush-shards, flush-wal, lost-unsynced-deletion-batch
Drop a relation during WAL rename or unlink durability failure; retries preserve tombstones and recovery prevents resurrection from the older WAL. ✅ pass live Live protocol and SDK transcripts: drop-retry, drop-old-wal, drop-unlink
Start with directory durability failures; initial and repeated startup refuse service until barriers succeed, then committed data survives restart. ✅ pass live Live protocol and SDK transcripts: startup-data, startup-persist, startup-wal, startup-batches, startup-shards, startup-wal-parent
Fail append sync, rollback, and cut-marker persistence; receive outcome_unknown, retain readable committed data, and reject subsequent writes across graphs until recovery. ✅ pass live Live protocol and SDK transcripts: unknown-wire, multi-wire
Execute single- and multi-statement writes through both SDKs; receive typed OutcomeUnknownError and StoreReadOnlyError with recovery restoring writes. ✅ pass live Live protocol and SDK transcripts: unknown-python, unknown-javascript, multi-python, multi-javascript
Delete a user during transient API-key cleanup failure; remaining ACL cleanup runs, premature recreation is refused, and successful retry leaves no inherited access or usable old key. ✅ pass live Live protocol and SDK transcripts: user-cleanup
  • cargo build --bin inputlayer-server
  • cargo test --lib durability -- --nocapture
  • cargo test --lib user_drop_cleanup -- --nocapture
  • cargo test --lib storage::persist::wal::tests -- --nocapture (corrected an initial selector that matched zero tests)
  • cargo test --test wal_transaction_recovery -- --nocapture
  • packages/inputlayer-js/node_modules/.bin/vitest run --root packages/inputlayer-js tests/statement-errors.test.ts
  • PYTHONPATH=packages/inputlayer-py/src .wal-live/venv/bin/python -m pytest packages/inputlayer-py/tests/test_statement_errors.py -q
  • Built the isolated filesystem fault injector with cc -shared -fPIC -o .wal-live/fault.so .wal-live/fault.c -ldl -pthread.
  • Bundled the production JS connection with repository-local esbuild for live SDK execution.
  • Ran .wal-live/live.py, .wal-live/adversarial.py, .wal-live/multi.py, and .wal-live/boundaries.py against isolated production server processes, using real WebSockets, controlled filesystem failures, SIGKILL restarts, and explicit crash-state models.
  • Saved protocol transcripts, SDK exceptions, server fault logs, and reproduction scripts; removed disposable environments, data, and newly generated build outputs. Final git status was clean.

✅ No issues found.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
An acknowledged write survives SIGKILL, and a deleted fact stays deleted across two more restarts ✅ pass live driver.log group basic; acknowledged-and-deleted-*.log
A write whose WAL fsync fails is reported failed and is gone after restart (rollback succeeds, and also when rollback fails and a cut marker is recorded) ✅ pass live driver.log group rollback; failed-write-rollback-.log, failed-write-marker-.log
Repair retries that hit WAL file/dir fsync failures keep refusing writes without resurrecting the failed record; the next good write survives restart ✅ pass live driver.log group repair; repair-file-.log, repair-directory-.log
Flushing a deletion with a batches/shards/wal directory fsync failure never brings the deleted fact back after restarts ✅ pass live driver.log group flush; flush-*.log
When append fsync, rollback and cut marker all fail, the client gets outcome_unknown (wire, Python OutcomeUnknownError, JS OutcomeUnknownError), the next write gets store_read_only, and restart recove… ✅ pass live driver.log group unknown; unknown-wire/python/javascript-*.log
Adversarial: with handler pre-checks removed, every write family (facts, rules, conditional delete, user create/password/role/drop, ACL grant/revoke, KG create/drop) is still refused with store_read_o… ✅ pass live driver.log group readonly_no_prechecks; readonly-families-*.log
Startup fsync failure on the persist-root parent (or root, wal, batches, shards) is fatal; restarting with the root already present re-attempts that fsync (and is refused again), then a clean start ac… ✅ pass live driver.log group startup; startup-data-1.log and startup-data-2.log show FAULT fsync .../data and exit 1 both times
A pre-existing data dir below a non-readable (0311) ancestor still starts and persists commits across restart ✅ pass live driver.log group unreadable_ancestor; unreadable-ancestor-*.log
.kg drop whose ACL cleanup hits a plain WAL failure returns the applied drop result plus a 'not removed' warning row (no error), and the KG stays dropped after restart ✅ pass live kg-drop-driver.log scenario kg-drop-cleanup-failed
Adversarial: .kg drop whose ACL cleanup hits an unknown outcome returns typed outcome_unknown, then later writes get store_read_only, and restart recovers ✅ pass live kg-drop-driver.log scenario kg-drop-cleanup-unknown
  • cargo build --bin inputlayer-server --bin inputlayer-client
  • python live2.py (groups basic, rollback, repair, flush, unknown, startup, unreadable_ancestor, readonly_no_prechecks) against target/debug/inputlayer-server with LD_PRELOAD fault.so
  • python live2.py kg_drop_cleanup (rerun after harness fix)
  • cargo test --lib -- storage::persist::durability storage::persist::wal::tests storage_engine::program_commit kg_drop_keeps_applied_result user_drop_cleanup read_only
  • uv run --with pytest ... pytest tests/test_statement_errors.py (Python SDK)
  • npx vitest run tests/statement-errors.test.ts (JS SDK)
🔧 **Document** - 2 issues found → auto-fixed ✅
  • ⚠️ gui/lib/ws-types.ts:133 - The hand-copied WsErrorCode union is missing the new wire codes outcome_unknown and store_read_only defined in ws-protocol/src/error.rs and docs/spec/asyncapi.yaml. I left it alone because changing it is a GUI type change, not a documentation edit. Suggested follow-up: add the two codes, or generate this type from the AsyncAPI ErrorCode schema so it can't drift again.
  • ℹ️ docs/guides/persistence.md:1 - docs/guides/.md are copies of docs/content/docs/guides/.mdx that have drifted apart (persistence compaction settings, core-concepts deletion patterns). As the operator required, this change keeps the WAL contract in both persistence guides, and the stale Programs Commit Together section in core-concepts.md now just links to the .mdx version. Suggested follow-up: reduce the docs/guides copies to pointers or remove them, so each fact lives in one place.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

jsam added 4 commits October 3, 2026 22:28
…te outcomes

Rebased onto main (9ed61b4), reconciling with request control, API key
expiry and the outcome_unknown wire code. Squashes:

- fix(persist): exact WAL commit contract across flush and cut failures
- no-mistakes(review): Enforce durability barriers and typed unknown write outcomes
- no-mistakes(review): Preserve partial WAL outcomes, directory durability, and graph switches
- no-mistakes(review): Complete user cleanup and remove redundant outcome variants
- no-mistakes(document): Document unknown WAL outcomes and read-only store errors
- no-mistakes(document): Add outcome_unknown and store_read_only to GUI WsErrorCode
…hed`, which enforces that a commit to a shard must use a revision higher than what that shard has already flushed. Otherwise replay would skip the record and an acknowledged write would be lost. Three tests from main in tests/persist_crash.rs broke this caller contract: they committed every write at revision 1, even after a flush (including the flush during reopen-time replay). They now fail with "revision 1 is not after the revisions shard db:r has flushed". The check is correct and required by the intent ("a committed write always survives"), so the fix is in the tests only. What I changed in tests/persist_crash.rs: - `concurrent_appends_and_flushes_lose_no_acked_write`: each writer thread now writes to its own shard `db:r{t}` at rising revisions i+1. Concurrent writers sharing one shard can't keep revisions in commit order, and real callers allocate revisions per knowledge graph. Appends and auto-flushes (buffer_size 7) still run concurrently through the shared WAL, and the test checks that every shard recovers all PER_THREAD writes after a crash. - `concurrent_append_and_delete_recover_what_was_acked`: pre-flushed batches use revisions i+1; the writer uses BATCHES+i+1. - `torn_tail_after_valid_records_is_truncated`: the commit after reopening uses revision 2. Verification: - `cargo test --test persist_crash`: 7/7 pass. - These persistence integration tests also pass: crash_recovery_tests (28), name_validation_tests (11), persist_roundtrip (11), restart_equivalence (8), wal_transaction_recovery (4). - cargo fmt is clean. - Not finished: a background run of the remaining integration binaries had not reported when this result was required. - To make room for the build (the disk was 100% full), I deleted target/debug/incremental and stale test binaries in the worktree's own target/ directory
@jsam
jsam merged commit b7f7ad6 into main Oct 3, 2026
15 checks passed
@jsam
jsam deleted the fm/il-wal1 branch October 3, 2026 23:08
jsam added a commit that referenced this pull request Oct 4, 2026
…ry request (#249)

The release-checkpoint performance gate fails on main: cheap_query serial
p50 is 1.110x the approved baseline e732cd5 on the 32-vCPU runner
(115 -> 126 us). Concurrent latency and throughput did not move. Bisected
to #214 (per-connection request pipelining), made worse by #222.

Cause: the pipeline runs requests in a `FuturesUnordered`. Its `poll_next`
wakes its own task and returns `Pending` after any poll that polled every
future in the set. With one request in flight, which is every serial
client, that happens on each request's first poll. The connection task
wakes itself, tokio schedules that as a yield, and a yield wakes an idle
worker to take the task. Each request therefore costs one more context
switch (4.0 -> 5.0 per query) and a hand-off of the connection task to
another core. #222 added no work per query but made the hand-off dearer:
user cycles per query rose 15% at the same instruction count.

Fix: keep one never-woken future in the set. After the pipeline's first
poll it is never polled again, so no poll reaches every future and the
forced yield no longer fires. The yield after futures that wake
themselves still applies. Pipelining, ordering, deadlines, cancellation
and the credential fence are unchanged. Nothing is added per request: no
lock, atomic or allocation. Each connection gets one more set node.

Bisect (cheap_query, `?edge(1, Y)`, serial). Server counters per query
from perf stat. Ratios are against e732cd5, from an interleaved probe
with one pinned server per commit, 40 rounds:

| commit | PR | user instr/query | ctx switches/query | p50 ratio |
|---|---|---|---|---|
| e732cd5 | baseline | 1.282M | 4.0 | 1.000 |
| d75ce6e | #208 atomic programs | 1.279M | 4.0 | 0.993 |
| 4578fe6 | #215 notification stream | 1.271M | 4.0 | 0.991 |
| 71a4d24 | #216 online backup | 1.275M | 4.0 | 1.016 |
| 2f621c5 | #200 first-update latency | 1.272M | 4.0 | 1.009 |
| e96df5c | #207 | 1.279M | 4.0 | 0.998 |
| 4dd8fe9 | #209 shard index | 1.272M | 4.0 | 0.987 |
| 277c7f0 | #206 | 1.274M | 4.0 | 1.008 |
| 3554c98 | #214 pipelining, deadlines | 1.296M | 5.0 | 1.020 |
| 3492a59 | #213 | 1.294M | 5.0 | 1.030 |
| 2ba9a5a | #212 provenance | 1.297M | 5.0 | 1.029 |
| 9ed61b4 | #204 key expiry | 1.297M | 5.0 | 1.027 |
| be35ee3 | #221 | 1.297M | 5.0 | 1.013 |
| c98a590 | #222 bounded streaming | 1.298M | 5.0 | 1.047 |
| a6fd628 | #219 wildcard fix | 1.297M | 5.0 | 1.061 |
| b7f7ad6 | #220 WAL durability | 1.300M | 5.0 | 1.056 |
| de54807 | #223 shared standing eval | 1.299M | 5.0 | 1.071 |
| 55ec34d | #225 checkpoint-1 fixes | 1.300M | 5.0 | 1.045 |
| 23712b8 | main | 1.300M | 5.0 | 1.064 |
| this change | | 1.300M | 4.0 | 1.033 |

Confirming A/Bs with the gate's own fixture (fresh pinned servers each
round, 40 rounds, ratio [95% interval]):

- #214 vs its parent 277c7f0: 1.039 [1.027..1.049]
- #222 vs its parent be35ee3: 1.025 [1.006..1.044]
- A/A 3554c98: 1.010 [1.002..1.025]
- e732cd5 -> main: 1.048 [1.028..1.066]
- main -> this change: 0.978 [0.958..0.989]
- e732cd5 -> this change: 1.021 [1.009..1.044], PASS (budget 1.05)

User cycles per query (perf stat, same instruction count): e732cd5 958K,
be35ee3 1053K, c98a590 1206K, this change 1006K.

Full gate, all fixtures, 20 rounds, servers pinned to CPUs 2-15:

- main -> this change: no metric worse. Every required metric is PASS, or
  INCONCLUSIVE with an interval that includes 1.0. cheap_query p50 0.979
  [0.953..1.007].
- e732cd5 -> this change: cheap_query p50 1.029 [1.019..1.072],
  INCONCLUSIVE on a busy host (load average up to 22); bound_query p50
  1.022, PASS. The insert and delta metrics improved since the baseline.

The shared host is about 4x slower per query than the CI runner, so the
same hand-off is a smaller share of each query here than there.

The remaining ~1.7% of user instructions per query since #214 is the
pipeline's own per-request bookkeeping: the boxed request future (one
4-8 KiB allocation), its set node, the request control and the in-flight
maps.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant