Skip to content

Commit 696c495

Browse files
fix(soundness): FFI octad ops fail loudly instead of reporting false success (#179)
## Summary Closes five soundness holes found in a focused audit — places where the FFI/validation reported success without delivering it. The most serious: **`verisimiser_verify_provenance` returned `.ok` ("verified") without walking the hash chain**, so a *tampered* provenance chain passed as verified. `record_provenance`, `record_version`, and `enable_dimension` likewise validated their enums and then silently no-op'd while returning `.ok`, so callers believed data had been recorded / a dimension enabled when nothing happened. The Idris2 ABI proofs (`HashChain`/`Version`/`Octad`) and the Rust `tier1` library are sound; the gap was the un-wired Zig FFI layer claiming success. Per doctrine ("fail loudly, seal soundly — no silent green"), these now return `.sidecar_unavailable` until the sidecar persistence is wired in (that wiring is breadth, deferred). Argument-validation paths (null handle → `.null_pointer`, invalid enum → `.invalid_param`) are unchanged. No `Result` variant was added, so the ABI-FFI gate stays green. Also fixed: `validate_manifest` never called `effective_backend()`, so a manifest with conflicting `[database].backend` and legacy `target-db` passed validation and only failed later at generate time — now surfaced up front as a failed `backend-unambiguous` check. ## Changes - `src/interface/ffi/src/main.zig`: `verify_provenance` / `record_provenance` / `record_version` / `enable_dimension` return `.sidecar_unavailable` (with a clear error message) instead of a false `.ok`. - `src/manifest/mod.rs`: `validate_manifest` adds a `backend-unambiguous` check that exercises `effective_backend()`. ## Testing - Zig: added tests asserting each of the four ops does **not** return `.ok` with a valid handle (and returns `.sidecar_unavailable`). Existing null/invalid-enum tests unchanged. (`zig test` runs in the ABI-FFI gate.) - Rust: added `conflicting_backend_fails_validation`. Full suite green under the CI toolchain (1.96.0): `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --locked --all-targets` (146 lib tests). ## RSR Quality Checklist - [x] Tests pass - [x] Formatted / linter clean - [x] No banned language patterns - [x] No banned functions (proofs untouched) - [x] SPDX headers present on modified files - [x] No secrets 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01A6PSzJWpRxtzGDjUCEh7Mx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce26ae4 commit 696c495

2 files changed

Lines changed: 121 additions & 17 deletions

File tree

src/interface/ffi/src/main.zig

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,11 @@ export fn verisimiser_enable_dimension(
246246
return .invalid_param;
247247
};
248248

249-
// TODO: actually enable the dimension in the overlay index
250-
251-
clearError();
252-
return .ok;
249+
// The overlay index that persists per-entity dimension state is not yet
250+
// wired into the FFI. Fail loudly rather than report a dimension as enabled
251+
// when it is not (soundness: no silent success).
252+
setError("octad dimension overlay not yet wired into the FFI");
253+
return .sidecar_unavailable;
253254
}
254255

255256
/// Get the active dimension bitmask for an entity.
@@ -296,10 +297,11 @@ export fn verisimiser_record_provenance(
296297
return .invalid_param;
297298
};
298299

299-
// TODO: compute SHA-256 hash, chain from previous, write to sidecar
300-
301-
clearError();
302-
return .ok;
300+
// The SHA-256 hash-chain sidecar append is not yet wired into the FFI.
301+
// Fail loudly rather than report a provenance event as recorded when no
302+
// entry was written (soundness: no phantom audit trail).
303+
setError("provenance sidecar not yet wired into the FFI");
304+
return .sidecar_unavailable;
303305
}
304306

305307
/// Verify the integrity of an entity's provenance hash chain.
@@ -319,11 +321,12 @@ export fn verisimiser_verify_provenance(
319321

320322
_ = entity_id;
321323

322-
// TODO: walk the hash chain, verify each link
323-
// Return .chain_corrupted if any link fails verification
324-
325-
clearError();
326-
return .ok;
324+
// The hash-chain store is not yet wired into the FFI, so integrity cannot
325+
// be confirmed. Return an error rather than .ok — reporting "verified" for
326+
// an unchecked (possibly tampered) chain would be the worst kind of
327+
// soundness hole.
328+
setError("provenance sidecar not yet wired into the FFI; cannot verify integrity");
329+
return .sidecar_unavailable;
327330
}
328331

329332
/// Get the length of an entity's provenance chain.
@@ -364,10 +367,11 @@ export fn verisimiser_record_version(
364367
_ = snapshot_ptr;
365368
_ = snapshot_len;
366369

367-
// TODO: store snapshot in temporal sidecar with current timestamp
368-
369-
clearError();
370-
return .ok;
370+
// The temporal sidecar that stores version snapshots is not yet wired into
371+
// the FFI. Fail loudly rather than report a version as recorded when no
372+
// snapshot was stored (soundness: no phantom history).
373+
setError("temporal sidecar not yet wired into the FFI");
374+
return .sidecar_unavailable;
371375
}
372376

373377
/// Query entity state at a specific point in time.
@@ -614,3 +618,46 @@ test "enable dimension with invalid dimension" {
614618
const result = verisimiser_enable_dimension(handle, 42, 99);
615619
try std.testing.expectEqual(Result.invalid_param, result);
616620
}
621+
622+
// Soundness: the persistence-backed octad operations are not yet wired into
623+
// the FFI, so they must fail loudly rather than report a false success.
624+
625+
test "verify provenance does not falsely report verified" {
626+
const handle = verisimiser_init() orelse return error.InitFailed;
627+
defer verisimiser_free(handle);
628+
629+
// A valid handle + entity must NOT return .ok while verification is unwired:
630+
// claiming a chain is verified without checking it would be unsound.
631+
const result = verisimiser_verify_provenance(handle, 42);
632+
try std.testing.expect(result != Result.ok);
633+
try std.testing.expectEqual(Result.sidecar_unavailable, result);
634+
}
635+
636+
test "record provenance does not falsely report recorded" {
637+
const handle = verisimiser_init() orelse return error.InitFailed;
638+
defer verisimiser_free(handle);
639+
640+
const result = verisimiser_record_provenance(handle, 42, 0, 0);
641+
try std.testing.expect(result != Result.ok);
642+
try std.testing.expectEqual(Result.sidecar_unavailable, result);
643+
}
644+
645+
test "record version does not falsely report stored" {
646+
const handle = verisimiser_init() orelse return error.InitFailed;
647+
defer verisimiser_free(handle);
648+
649+
const result = verisimiser_record_version(handle, 42, 0, 0);
650+
try std.testing.expect(result != Result.ok);
651+
try std.testing.expectEqual(Result.sidecar_unavailable, result);
652+
}
653+
654+
test "enable dimension does not falsely report enabled" {
655+
const handle = verisimiser_init() orelse return error.InitFailed;
656+
defer verisimiser_free(handle);
657+
658+
// Dimension 2 (provenance) is a valid enum value; the call must still fail
659+
// loudly because the overlay index is not wired in.
660+
const result = verisimiser_enable_dimension(handle, 42, 2);
661+
try std.testing.expect(result != Result.ok);
662+
try std.testing.expectEqual(Result.sidecar_unavailable, result);
663+
}

src/manifest/mod.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,43 @@ mod validate_manifest_tests {
372372
assert!(report.failed_count() == 0);
373373
}
374374

375+
/// A manifest that sets both `[database].backend` and the legacy
376+
/// `target-db` to conflicting values must fail validation up front, not
377+
/// silently pass and blow up later at generate time (V-L2-E1).
378+
#[test]
379+
fn conflicting_backend_fails_validation() {
380+
let dir = tempfile::tempdir().expect("tempdir");
381+
let path = dir.path().join("verisimiser.toml");
382+
let sidecar_path = dir.path().join("sidecar.db");
383+
let body = format!(
384+
"[project]\n\
385+
name = \"test\"\n\
386+
[database]\n\
387+
backend = \"sqlite\"\n\
388+
target-db = \"postgresql\"\n\
389+
[sidecar]\n\
390+
storage = \"sqlite\"\n\
391+
path = \"{}\"\n",
392+
sidecar_path.display().to_string().replace('\\', "/")
393+
);
394+
std::fs::write(&path, body).expect("write");
395+
396+
let report = validate_manifest(path.to_str().unwrap());
397+
assert!(
398+
!report.passed,
399+
"conflicting backend/target-db must fail validation; checks: {:?}",
400+
report.checks
401+
);
402+
assert!(
403+
report
404+
.checks
405+
.iter()
406+
.any(|c| c.name == "backend-unambiguous" && !c.passed),
407+
"expected a failed 'backend-unambiguous' check; checks: {:?}",
408+
report.checks
409+
);
410+
}
411+
375412
/// A schema-source pointing at a missing file must fail
376413
/// `schema-source-exists`.
377414
#[test]
@@ -960,6 +997,26 @@ pub fn validate_manifest(path: &str) -> ValidationReport {
960997
},
961998
},
962999
);
1000+
1001+
// 5. Backend selection is unambiguous. `effective_backend()` rejects a
1002+
// manifest that sets both [database].backend and the legacy
1003+
// [database].target-db to conflicting values (V-L2-E1). Validation must
1004+
// exercise it, otherwise a latent conflict passes `validate` only to
1005+
// fail later at generate time.
1006+
let backend_check = ValidationCheck {
1007+
name: "backend-unambiguous".to_string(),
1008+
description: "[database].backend and legacy target-db do not conflict".to_string(),
1009+
passed: true,
1010+
detail: None,
1011+
};
1012+
checks.push(match m.database.effective_backend() {
1013+
Ok(_) => backend_check,
1014+
Err(e) => ValidationCheck {
1015+
passed: false,
1016+
detail: Some(e.to_string()),
1017+
..backend_check
1018+
},
1019+
});
9631020
}
9641021

9651022
let passed = checks.iter().all(|c| c.passed);

0 commit comments

Comments
 (0)