Skip to content

Commit e51d345

Browse files
plan(#31,#32): fork-impossibility fix — design + failing test (#104)
#31 (per-entity write lock) and #32 (UNIQUE INDEX(entity_id, previous_hash)) together design the provenance chain so that a forked history *cannot be represented*. Issues #31/#32 frame forks as purely adversarial; that is incomplete. Network-partitioned/replicated honest writers and simulation branches (ADR-0006) are legitimate divergence. A UNIQUE INDEX on (entity_id, previous_hash) does not *detect* a fork — it *rejects the second row at insert time*, discarding honest history. A fork that cannot be written cannot be detected or audited. That is the integrity defect. This is a planning skeleton — design doc + a failing-by-design test. No implementation; the parent session controls merges. Contents: * docs/decisions/0010-provenance-forks-are-first-class.adoc — ADR (estate .adoc default). Decision: forks are first-class. Concretely: - #32: do NOT add UNIQUE INDEX(entity_id, previous_hash). The `hash` PRIMARY KEY (preimage is domain-tagged + covers every field, per ADR-0002) already rejects exact-duplicate rows — that is the correct duplicate guard. Add a NON-unique idx_provenance_predecessor(entity_id, previous_hash) for O(log n) fork detection. - #31: verisimdb_provenance_chain_head (entity_id PK, one head) becomes verisimdb_provenance_chain_heads (PK(entity_id, head_hash), a SET of tips). `append_provenance` keeps BEGIN IMMEDIATE (still serialises racing *duplicate* appends from one node) but removes parent-tip + adds new-tip on linear append; a new `append_provenance_fork(... from_hash ...)` adds a head without removing one. - Detection surface: `fork_points(conn, entity)`; `verify_chain` becomes per-branch (each head -> genesis walk hash-consistent), so divergence is never conflated with tampering. - Data migration: idempotent CREATE-IF-NOT-EXISTS + INSERT..SELECT copy of the head table guarded by a sqlite_master check; old table left for one release (no destructive step ships). The log table is unchanged. Because the unique index is never created, an existing sidecar that already contains a legitimate fork cannot fail to open — a hazard that WOULD exist had #32 shipped first (flagged in the #32 thread). * tests/provenance_fork_test.rs — failing-by-design test. Writes genesis + branch A (supported linear path) + branch B (a second legitimate child of genesis). Asserts both children persist AND the entity records two heads. Compiles against the current public surface; the assertions, not the compile, fail. Verified red on this branch: child_count==2 passes (log keeps both rows) but head_count is 1 not 2 — the single-head table collapses branch B. Exactly the #31 defect, in executable form. (With #32's unique index applied, the branch-B insert would additionally fail with a constraint violation — also encoded in the ADR test plan.) Build/test: lib builds clean (only the pre-existing unrelated gc.rs RetentionConfig warning). New test compiles and FAILS as intended (1 failed, by design). Unblocked by #26 / PR #103 — there is now one ProvenanceEntry and one compute_hash for the fork-aware append/verify to evolve. Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4b3e195 commit e51d345

2 files changed

Lines changed: 300 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
= Architecture Decision Record: 0010-provenance-forks-are-first-class
2+
<!-- SPDX-License-Identifier: PMPL-1.0-or-later -->
3+
<!-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> -->
4+
5+
# 10. Provenance forks are first-class; prevent duplicates, not divergence
6+
7+
Date: 2026-05-16
8+
9+
## Status
10+
11+
Proposed (design + failing test; tracks #31 and #32)
12+
13+
## Context
14+
15+
Two issues, taken together, design the provenance chain into a
16+
structure that *cannot represent a forked history*:
17+
18+
* **#31 (V-L2-L1)** — a per-entity write lock. The current
19+
implementation in `src/tier1/provenance.rs::append_provenance`
20+
already wraps read-head + insert + update-head in a
21+
`BEGIN IMMEDIATE` transaction, and `verisimdb_provenance_chain_head`
22+
holds exactly one `head_hash` per `entity_id`. The chain head is a
23+
*single scalar*: there is no way to record that an entity has two
24+
valid tips.
25+
* **#32 (V-L2-L2)** — proposes
26+
`CREATE UNIQUE INDEX ux_provenance_chain
27+
ON verisimdb_provenance_log(entity_id, previous_hash)`.
28+
This makes it *structurally impossible* to insert a second row that
29+
chains from the same predecessor.
30+
31+
Both issues frame forks as purely adversarial ("a second writer that
32+
sneaks past the lock"). That framing is incomplete. Legitimate
33+
divergence is a real, expected event in this system:
34+
35+
* **Partitioned / replicated / offline writers.** The threat-model
36+
doc itself (section 5, OQ-2 external anchoring; and ADR-0006
37+
simulation semantics) anticipates replicated and sandbox writers.
38+
Two honest writers that are network-partitioned both legitimately
39+
extend the chain from the last shared tip. When they reconcile,
40+
*both* branches are true history and must be retained for audit and
41+
later merge.
42+
* **Simulation / what-if branches** (Simulation octad, ADR-0006). A
43+
what-if branch is, by construction, a provenance fork from a real
44+
entity's chain.
45+
46+
The integrity property we actually want is **tamper-evidence and
47+
no silent loss**, not **linearity**. The current/proposed design
48+
inverts this: a `UNIQUE INDEX(entity_id, previous_hash)` does not
49+
*detect* a fork — it *rejects the second row at insert time*. The
50+
second writer's legitimate history is never recorded. The system
51+
cannot answer "did this entity's history diverge?" because the
52+
divergent row was thrown away. **A fork that cannot be written
53+
cannot be detected or audited. That is the integrity defect.**
54+
55+
A hash chain that forbids forks is equivalent to claiming the world
56+
never partitions. It does.
57+
58+
## Decision
59+
60+
**Provenance forks are a first-class, representable state. The
61+
storage layer prevents *duplicate* records; it does not prevent
62+
*divergent* ones. Detection and reconciliation of forks is an
63+
explicit, queryable operation, not an insert-time rejection.**
64+
65+
### 1. Schema (#32): duplicate-prevention, not fork-prevention
66+
67+
Do **not** add `UNIQUE INDEX(entity_id, previous_hash)`. Instead:
68+
69+
* The `hash` column is already `PRIMARY KEY`. Because the hash
70+
preimage is domain-tagged and covers `previous_hash`, `entity_id`,
71+
`operation`, `actor`, the canonical timestamp, `before_snapshot`
72+
and `transformation` (see ADR-0002 / #27), an *exact duplicate*
73+
record necessarily collides on `hash` and is already rejected.
74+
This is the correct duplicate guard: it forbids re-inserting the
75+
*same* entry while saying nothing about two *different* entries
76+
that share a `previous_hash`.
77+
* Add a non-unique index to make fork *detection* O(log n):
78+
+
79+
[source,sql]
80+
----
81+
CREATE INDEX IF NOT EXISTS idx_provenance_predecessor
82+
ON verisimdb_provenance_log(entity_id, previous_hash);
83+
----
84+
+
85+
Two children of the same predecessor are two rows with the same
86+
`(entity_id, previous_hash)` and distinct `hash` — a `GROUP BY
87+
... HAVING COUNT(*) > 1` over this index is the fork query.
88+
89+
### 2. Chain head (#31): a set of heads, not a scalar
90+
91+
`verisimdb_provenance_chain_head` becomes
92+
`verisimdb_provenance_chain_heads` keyed by `(entity_id, head_hash)`:
93+
an entity may have one head (linear, the common case) or several
94+
(forked). `append_provenance` keeps its `BEGIN IMMEDIATE`
95+
transaction — serialisation is still desirable to prevent *racing
96+
duplicate* appends from one node — but:
97+
98+
* It takes the parent tip explicitly (or, for the linear
99+
fast-path, the unique current head if exactly one exists).
100+
* On insert it **removes the parent hash from the head set and
101+
adds the new hash**, so a normal append stays linear.
102+
* A deliberate fork (`append_provenance_fork(... from_hash ...)`)
103+
inserts a new entry whose `previous_hash` is a *non-tip*
104+
ancestor, and *adds* a head without removing one. The entity now
105+
has two heads; both persist.
106+
107+
### 3. Detection / query surface
108+
109+
Add `fork_points(conn, entity_id) -> Vec<ForkPoint>` returning every
110+
predecessor hash with >1 child, and extend `verify_chain` to verify
111+
*per-branch* (walk each head back to genesis; every branch must be
112+
internally hash-consistent) rather than assuming one linear walk.
113+
114+
### 4. Data migration for existing sidecars
115+
116+
* The single-column `verisimdb_provenance_chain_head(entity_id PK,
117+
head_hash)` is migrated to `verisimdb_provenance_chain_heads(
118+
entity_id, head_hash, PRIMARY KEY(entity_id, head_hash))` by an
119+
idempotent `CREATE TABLE IF NOT EXISTS ... ; INSERT ... SELECT`
120+
copy guarded by a `sqlite_master` existence check (the old table
121+
is left in place for one release, then dropped — no destructive
122+
step in the migration that ships with this change).
123+
* **No `UNIQUE INDEX(entity_id, previous_hash)` is ever created**,
124+
so there is no risk of an existing sidecar that legitimately
125+
already contains a fork failing to open. (Had #32 shipped first,
126+
this migration would have to detect and quarantine such rows; by
127+
not shipping it we avoid that hazard entirely — note this in the
128+
#32 thread.)
129+
* `verisimdb_provenance_log` itself is unchanged (same columns,
130+
same `hash` PK). Existing rows remain valid and verifiable.
131+
132+
### 5. Test plan (the failing test ships in this branch)
133+
134+
`tests/provenance_fork_test.rs`:
135+
136+
* `fork_can_be_written_and_both_branches_persist` — genesis +
137+
child A; then a *second* child B chained from the genesis
138+
(the fork). Assert: both A and B rows exist, the log has 3
139+
rows for the entity, and the entity has 2 chain heads. **This
140+
test fails today** (`append_provenance` cannot express "chain
141+
from a non-tip ancestor"; there is no multi-head table; with #32
142+
applied the second insert would be a constraint violation).
143+
* `fork_points_detects_the_divergence` — after writing the fork,
144+
`fork_points(conn, entity)` returns the genesis hash as a fork
145+
point with two children.
146+
* `each_branch_verifies_independently``verify_chain` returns
147+
`true` for a forked entity (each branch is hash-consistent),
148+
proving divergence is not conflated with tampering.
149+
* Retained guard: `exact_duplicate_entry_is_rejected` — inserting
150+
a byte-identical entry twice fails on the `hash` PK (the
151+
duplicate guard the unique index was *trying* to provide,
152+
achieved correctly).
153+
154+
## Consequences
155+
156+
* The provenance model can represent and audit reality (partitions,
157+
replicas, simulation branches) instead of silently discarding the
158+
losing writer's history.
159+
* "Single-writer per entity" stops being a *correctness* requirement
160+
and becomes a *policy* a deployment may opt into (reject on >1
161+
head) — enforced in application code, not welded into the schema.
162+
* Slightly more complex head bookkeeping and a per-branch
163+
`verify_chain`. Acceptable: linearity was never a security
164+
property, only an availability assumption.
165+
* The threat-model doc (`docs/theory/provenance-threat-model.adoc`
166+
section "Single writer") and README §"Provenance Tracking" must be
167+
updated to describe forks as detected-and-retained rather than
168+
prevented. (Tracked as follow-up in the implementing PR.)
169+
170+
## References
171+
172+
* #31 (V-L2-L1) — write-path lock
173+
* #32 (V-L2-L2) — proposed unique index (this ADR declines it)
174+
* #26 / PR #103 — provenance type dedup (unblocker; landed first)
175+
* ADR-0002 — domain-tagged hash preimage (the real duplicate guard)
176+
* ADR-0006 — simulation semantics (a legitimate fork source)
177+
* `docs/theory/provenance-threat-model.adoc` §5

tests/provenance_fork_test.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// FAILING-BY-DESIGN test for the fork-impossibility defect
5+
// (#31 + #32, see docs/decisions/0010-provenance-forks-are-first-class.adoc).
6+
//
7+
// This test encodes the *desired* behaviour: a legitimate provenance
8+
// fork (two valid children of the same predecessor — e.g. two
9+
// network-partitioned honest writers, or a simulation branch) must be
10+
// representable, persisted, and detectable.
11+
//
12+
// It is EXPECTED TO FAIL on `main` today, because:
13+
// * `verisimdb_provenance_chain_head` has `entity_id` as PRIMARY KEY,
14+
// so an entity can only ever record ONE head — the second branch's
15+
// head is silently overwritten (INSERT OR REPLACE).
16+
// * there is no fork-aware append / detection surface.
17+
// * if #32's `UNIQUE INDEX(entity_id, previous_hash)` were applied,
18+
// the second child insert would additionally fail with a
19+
// constraint violation.
20+
//
21+
// The implementing PR for #31/#32 makes this test pass (multi-head
22+
// table + fork-aware append + `fork_points`). Until then it documents
23+
// the defect in executable form.
24+
//
25+
// It compiles against the *current* public surface so CI exercises it
26+
// rather than ignoring it; the assertions — not the compile — are what
27+
// fail.
28+
29+
use rusqlite::{params, Connection};
30+
use verisimiser::abi::ProvenanceEntry;
31+
use verisimiser::tier1::provenance::{append_provenance, init_sidecar_schema};
32+
33+
fn open_sidecar() -> Connection {
34+
let conn = Connection::open_in_memory().expect("open in-memory sidecar");
35+
init_sidecar_schema(&conn).expect("init sidecar schema");
36+
conn
37+
}
38+
39+
/// Count chain heads recorded for an entity. Today this can only ever
40+
/// be 0 or 1 because `entity_id` is the PRIMARY KEY of the head table;
41+
/// the target design records one row per live branch tip.
42+
fn head_count(conn: &Connection, entity_id: &str) -> i64 {
43+
conn.query_row(
44+
"SELECT COUNT(*) FROM verisimdb_provenance_chain_head WHERE entity_id = ?1",
45+
[entity_id],
46+
|r| r.get(0),
47+
)
48+
.unwrap_or(0)
49+
}
50+
51+
/// Number of rows in the log whose `previous_hash` is `parent` — i.e.
52+
/// how many children that node has. > 1 ==> a fork at `parent`.
53+
fn child_count(conn: &Connection, entity_id: &str, parent: &str) -> i64 {
54+
conn.query_row(
55+
"SELECT COUNT(*) FROM verisimdb_provenance_log \
56+
WHERE entity_id = ?1 AND previous_hash = ?2",
57+
params![entity_id, parent],
58+
|r| r.get(0),
59+
)
60+
.unwrap_or(0)
61+
}
62+
63+
#[test]
64+
fn fork_can_be_written_and_both_branches_persist() {
65+
let mut conn = open_sidecar();
66+
let entity = "account:42";
67+
68+
// Genesis + one normal child via the supported linear path.
69+
let genesis = append_provenance(
70+
&mut conn, entity, "accounts", "insert", "alice", None, None,
71+
)
72+
.expect("genesis append");
73+
let _branch_a = append_provenance(
74+
&mut conn, entity, "accounts", "update", "alice", None, None,
75+
)
76+
.expect("branch A append");
77+
78+
// A second, legitimate writer (partitioned from the first) extends
79+
// the chain from the SAME genesis tip: a fork. There is no
80+
// supported API for "chain from this specific ancestor" yet, so we
81+
// construct the entry the way the target `append_provenance_fork`
82+
// will and write it directly. The hash is canonical and the row is
83+
// internally valid — it is honest history, not tampering.
84+
let ts = chrono::Utc::now();
85+
let branch_b_hash = ProvenanceEntry::compute_hash(
86+
&genesis, entity, "update", "bob", &ts, None, None,
87+
);
88+
conn.execute(
89+
"INSERT INTO verisimdb_provenance_log \
90+
(hash, previous_hash, entity_id, table_name, operation, actor, \
91+
timestamp, before_snapshot, transformation) \
92+
VALUES (?1, ?2, ?3, 'accounts', 'update', 'bob', ?4, NULL, NULL)",
93+
params![branch_b_hash, genesis, entity, ts.to_rfc3339()],
94+
)
95+
.expect("fork row insert (fails here once #32 unique index is added)");
96+
97+
// The target design also records branch B's head. Today the head
98+
// table cannot hold two heads for one entity (entity_id is PK), so
99+
// we attempt the insert the implementing PR will do.
100+
let _ = conn.execute(
101+
"INSERT INTO verisimdb_provenance_chain_head (entity_id, head_hash) \
102+
VALUES (?1, ?2)",
103+
params![entity, branch_b_hash],
104+
);
105+
106+
// --- Desired-behaviour assertions (expected to FAIL on main) ---
107+
108+
// Both children of genesis must be retained: this is a true fork.
109+
assert_eq!(
110+
child_count(&conn, entity, &genesis),
111+
2,
112+
"genesis must have two children (branch A + branch B) — the \
113+
fork must be representable, not silently collapsed",
114+
);
115+
116+
// The entity now has two live branch tips; both must be tracked.
117+
assert_eq!(
118+
head_count(&conn, entity),
119+
2,
120+
"a forked entity must record one head per branch; today the \
121+
single-row-per-entity head table cannot express this (#31)",
122+
);
123+
}

0 commit comments

Comments
 (0)