From f9c090518f91cfa47096694e5a0a2c5fb267a7dc Mon Sep 17 00:00:00 2001 From: Agent57 Date: Sat, 1 Aug 2026 20:28:15 -0400 Subject: [PATCH] fix(audit): length-framed v2 hash encoding; verify rows by stored version compute_hash concatenated variable-length fields into the SHA-256 preimage with no framing, so the boundary between adjacent fields could shift without changing the digest: (object_id "x", detail 12) and (object_id "x1", detail 2) hash identically, and a detail edit could survive verify_chain -- defeating the tamper-evidence the field is documented to provide (#4173). A regression test pins the exact collision pair. Because any preimage change invalidates existing chains, the fix is a versioned encoding rather than a hard cutover: - audit_log gains hash_version SMALLINT NOT NULL DEFAULT 1 (migration 0027 + schema/schema.sql). Existing rows stay v1 and keep verifying byte-for-byte (pinned by a frozen digest literal); no data migration. - v2 (all new entries): a leading version byte, then every variable-length field length-prefixed with u64-BE; presence tags kept and extended to prev_hash (closing v1's None-vs-Some(GENESIS) ambiguity while the encoding can still change). - The write path is structurally v2-only: log_inner stamps CURRENT_HASH_VERSION; NewAuditEntry has no version slot, so no caller can ever emit the legacy encoding. - verify_chain recomputes each row under its stored version. A v1 row following a v2 row is surfaced as a tracing::warn!, deliberately not a hard error: rolling deploys across this migration legitimately interleave v1 rows (from pre-column pods) after v2 rows, and those rows persist -- a hard error would permanently brand every such chain as tampered, making HashMismatch carry no signal (the #2637 failure mode). A PG-gated test pins that mixed-version chains verify. - DEFAULT 1 deliberately outlives this release: pods that predate the column still INSERT without naming it during a rolling deploy, and their rows are genuinely v1-hashed. Dropping the DEFAULT is a later cleanup. Unknown versions are a hard error (never silently mis-hashed), and downgrade-tampering a stored v2 row surfaces as HashMismatch because the recomputed preimage changes. Fixes #4173 Co-Authored-By: Claude Fable 5 Signed-off-by: Agent57 --- crates/buzz-audit/src/entry.rs | 13 ++ crates/buzz-audit/src/error.rs | 11 ++ crates/buzz-audit/src/hash.rs | 255 ++++++++++++++++++++++++- crates/buzz-audit/src/lib.rs | 4 +- crates/buzz-audit/src/service.rs | 105 +++++++++- crates/buzz-db/src/migration.rs | 17 +- migrations/0027_audit_hash_version.sql | 15 ++ schema/schema.sql | 5 + 8 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 migrations/0027_audit_hash_version.sql diff --git a/crates/buzz-audit/src/entry.rs b/crates/buzz-audit/src/entry.rs index 33b51f8cf3..bb69974c41 100644 --- a/crates/buzz-audit/src/entry.rs +++ b/crates/buzz-audit/src/entry.rs @@ -34,6 +34,19 @@ pub struct AuditEntry { pub detail: serde_json::Value, /// When the entry was recorded. pub created_at: DateTime, + /// Hash-encoding version `hash` was computed with. Rows predating the + /// column are 1 (the legacy unframed encoding — verify-only); every new + /// entry is stamped [`crate::hash::CURRENT_HASH_VERSION`] by the service, + /// never by callers. Serde default is 1 so entry JSON serialized before + /// this field existed still deserializes. + #[serde(default = "default_hash_version")] + pub hash_version: i16, +} + +/// Serde default for [`AuditEntry::hash_version`]: entries serialized before +/// the field existed were all v1. +fn default_hash_version() -> i16 { + 1 } /// Input for appending a new audit entry. `seq`, `prev_hash`, `hash`, and diff --git a/crates/buzz-audit/src/error.rs b/crates/buzz-audit/src/error.rs index b4ffd24d83..223c8d69a2 100644 --- a/crates/buzz-audit/src/error.rs +++ b/crates/buzz-audit/src/error.rs @@ -35,6 +35,16 @@ pub enum AuditError { #[error("unknown audit action in database")] UnknownAction, + /// An entry carries a `hash_version` this build does not implement — the + /// digest can be neither written nor verified. Failing hard beats + /// guessing an encoding: a hash must never silently stand in for one + /// computed differently. + #[error("unsupported audit hash_version {version}")] + UnsupportedHashVersion { + /// The unrecognised version value. + version: i16, + }, + /// A JSON serialization error occurred (e.g. while canonicalising `detail`). #[error("serialization error: {0}")] Serialization(#[from] serde_json::Error), @@ -69,6 +79,7 @@ mod tests { AuditError::ChainViolation { seq: 7 }, AuditError::HashMismatch { seq: 42 }, AuditError::UnknownAction, + AuditError::UnsupportedHashVersion { version: 9 }, ]; for err in &domain_errors { diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index 8d6091a00c..fdc3cbd950 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -4,8 +4,11 @@ use sha2::{Digest, Sha256}; use crate::entry::AuditEntry; use crate::error::AuditError; -/// The 32-byte sentinel hashed in place of `prev_hash` for a community's first -/// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes. +/// The 32-byte sentinel the **v1 encoding** hashes in place of `prev_hash` +/// for a community's first entry. Stored as `prev_hash = NULL`; hashed as +/// all-zero bytes. The v2 encoding uses a presence tag instead (see +/// `compute_hash_v2`), because the sentinel makes `None` indistinguishable +/// from a literal `Some(GENESIS_HASH)`. pub const GENESIS_HASH: [u8; 32] = [0u8; 32]; /// Reduce a timestamp to the precision the audit store round-trips. @@ -23,11 +26,26 @@ pub fn to_storage_precision(created_at: DateTime) -> DateTime { created_at.trunc_subsecs(6) } -/// SHA-256 over the entry's identity, chain, and context fields. +/// The legacy hash encoding: fields concatenated with no length framing. /// -/// Field order is fixed — changing it invalidates all existing chains. The -/// `community_id` is hashed first so chain identity carries the tenant: an entry -/// cannot be lifted out of one community's chain and re-verified inside another. +/// **Verify-only.** Adjacent variable-length fields (most acutely +/// `object_id` ‖ `canonical_json(detail)`) share no boundary marker, so two +/// distinct entries can collide — `("x", 12)` and `("x1", 2)` hash +/// identically (#4173). Kept byte-for-byte so rows written before +/// `hash_version` existed keep verifying; the write path never emits it. +pub const HASH_ENCODING_V1: i16 = 1; +/// The current hash encoding: every variable-length field length-prefixed. +pub const HASH_ENCODING_V2: i16 = 2; +/// The encoding stamped onto (and hashed into) every newly written entry. +pub const CURRENT_HASH_VERSION: i16 = HASH_ENCODING_V2; + +/// SHA-256 over the entry's identity, chain, and context fields, using the +/// encoding named by `entry.hash_version`. +/// +/// Within an encoding, field order is fixed — changing it invalidates all +/// chains written with that encoding. The `community_id` leads the fields so +/// chain identity carries the tenant: an entry cannot be lifted out of one +/// community's chain and re-verified inside another. /// /// `created_at` is normalized through [`to_storage_precision`] here rather than /// hashed as given. Write paths truncate before storing so the row matches the @@ -38,8 +56,20 @@ pub fn to_storage_precision(created_at: DateTime) -> DateTime { /// /// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is /// stable across machines and Rust versions. A serialization failure is a hard -/// error, never silently hashed as empty. +/// error, never silently hashed as empty — and so is an unrecognised +/// `hash_version`: guessing an encoding would make a forged digest +/// indistinguishable from a build skew. pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { + match entry.hash_version { + HASH_ENCODING_V1 => compute_hash_v1(entry), + HASH_ENCODING_V2 => compute_hash_v2(entry), + version => Err(AuditError::UnsupportedHashVersion { version }), + } +} + +/// Legacy unframed encoding — see [`HASH_ENCODING_V1`]. Byte-for-byte the +/// pre-`hash_version` preimage; never change it, only verify with it. +fn compute_hash_v1(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { let mut hasher = Sha256::new(); // Tenant binding: community_id leads the hash. hasher.update(entry.community_id.as_bytes()); @@ -72,6 +102,65 @@ pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { Ok(hasher.finalize().into()) } +/// Length-framed encoding — see [`HASH_ENCODING_V2`]. +/// +/// Same field order as v1, three differences: +/// - a leading version byte, so the two preimages can never be confused +/// byte-for-byte (defense in depth — dispatch keys on the stored +/// `hash_version` column, not on preimage sniffing; the column itself is +/// covered by digest divergence, not by inclusion: re-labelling a stored +/// row's version changes the recomputed preimage and surfaces as +/// [`crate::error::AuditError::HashMismatch`]); +/// - every variable-length field is prefixed with its byte length (u64 BE), +/// so no field boundary can shift into a neighbour (#4173). Fixed-width +/// fields (`community_id`, `seq`) stay unframed; +/// - `prev_hash` gets a presence tag like the other optional fields. In v1, +/// `None` hashes as [`GENESIS_HASH`], indistinguishable from +/// `Some(GENESIS_HASH)` — a fake-genesis ambiguity v2 closes while the +/// encoding is still new enough to change. +fn compute_hash_v2(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { + fn framed(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + + let mut hasher = Sha256::new(); + hasher.update([2u8]); + // Tenant binding: community_id leads the fields. + hasher.update(entry.community_id.as_bytes()); + hasher.update(entry.seq.to_be_bytes()); + framed( + &mut hasher, + to_storage_precision(entry.created_at) + .to_rfc3339() + .as_bytes(), + ); + framed(&mut hasher, entry.action.as_str().as_bytes()); + match &entry.actor_pubkey { + Some(pk) => { + hasher.update([1u8]); + framed(&mut hasher, pk); + } + None => hasher.update([0u8]), + } + match &entry.object_id { + Some(id) => { + hasher.update([1u8]); + framed(&mut hasher, id.as_bytes()); + } + None => hasher.update([0u8]), + } + framed(&mut hasher, canonical_json(&entry.detail)?.as_bytes()); + match &entry.prev_hash { + Some(h) => { + hasher.update([1u8]); + framed(&mut hasher, h); + } + None => hasher.update([0u8]), + } + Ok(hasher.finalize().into()) +} + /// Serialize a JSON value with sorted object keys for deterministic output. /// /// Propagates any scalar serialization error rather than substituting a @@ -122,6 +211,9 @@ mod tests { use chrono::Utc; use uuid::Uuid; + /// Legacy-encoding fixture: the pre-`hash_version` preimage the existing + /// tests below pin byte-for-byte. New-encoding tests use + /// [`sample_entry_v2`]. fn sample_entry() -> AuditEntry { AuditEntry { community_id: Uuid::from_u128(1), @@ -135,6 +227,14 @@ mod tests { created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&Utc), + hash_version: HASH_ENCODING_V1, + } + } + + fn sample_entry_v2() -> AuditEntry { + AuditEntry { + hash_version: HASH_ENCODING_V2, + ..sample_entry() } } @@ -263,6 +363,147 @@ mod tests { assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap()); } + /// The defect that motivated the v2 encoding (#4173), pinned as behavior: + /// v1 concatenates `object_id` and `canonical_json(detail)` with no + /// framing, so shifting the boundary yields the same preimage. v2's + /// length prefixes make the same pair distinct. + #[test] + fn v1_boundary_shift_collides_and_v2_does_not() { + let mut a = sample_entry(); + a.object_id = Some("x".into()); + a.detail = serde_json::json!(12); + let mut b = sample_entry(); + b.object_id = Some("x1".into()); + b.detail = serde_json::json!(2); + // The legacy encoding cannot tell these apart — a detail edit that + // shifts the boundary survives verification. + assert_eq!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap()); + + let a2 = AuditEntry { + hash_version: HASH_ENCODING_V2, + ..a + }; + let b2 = AuditEntry { + hash_version: HASH_ENCODING_V2, + ..b + }; + assert_ne!(compute_hash(&a2).unwrap(), compute_hash(&b2).unwrap()); + } + + #[test] + fn v2_deterministic_and_distinct_from_v1() { + let entry = sample_entry_v2(); + assert_eq!(compute_hash(&entry).unwrap(), compute_hash(&entry).unwrap()); + assert_eq!(compute_hash(&entry).unwrap().len(), 32); + // Same logical entry, different encoding → different digest. + assert_ne!( + compute_hash(&sample_entry()).unwrap(), + compute_hash(&sample_entry_v2()).unwrap() + ); + } + + #[test] + fn v2_sensitive_to_each_field() { + let base = sample_entry_v2(); + let h0 = compute_hash(&base).unwrap(); + + let mut e = base.clone(); + e.community_id = Uuid::from_u128(2); + assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.seq = 2; + assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.action = AuditAction::EventDeleted; + assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.actor_pubkey = Some(vec![0xcd; 32]); + assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.object_id = Some("different".into()); + assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.detail = serde_json::json!({"key": "value"}); + assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.prev_hash = Some(vec![0xff; 32]); + assert_ne!(h0, compute_hash(&e).unwrap()); + } + + #[test] + fn v2_presence_tag_distinguishes_none_from_empty() { + // Length framing alone would make None and Some(empty) adjacent + // zero-length runs; the presence tag keeps them distinct in v2 just + // as in v1. + let mut none = sample_entry_v2(); + none.actor_pubkey = None; + let mut empty = sample_entry_v2(); + empty.actor_pubkey = Some(Vec::new()); + assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap()); + + let mut none = sample_entry_v2(); + none.object_id = None; + let mut empty = sample_entry_v2(); + empty.object_id = Some(String::new()); + assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap()); + } + + /// Legacy chains must keep verifying: the v1 digest of a fixed entry is + /// pinned to a literal so no edit can silently move the v1 preimage. If + /// this assertion ever reds, v1 rows in the field can no longer be + /// verified — that is a data-compatibility break, not a test to update. + #[test] + fn v1_digest_is_byte_stable() { + assert_eq!( + hex::encode(compute_hash(&sample_entry()).unwrap()), + "5fd6c48ddbd39979bd7fcc94357d78b5fcffa0d1443a0cdda997ad7e22f4f2ce" + ); + } + + /// v1's genesis sentinel makes `prev_hash: None` collide with a literal + /// `Some(GENESIS_HASH)`; v2's presence tag distinguishes them. + #[test] + fn v2_distinguishes_genesis_none_from_literal_genesis_prev_hash() { + let mut none = sample_entry(); + none.prev_hash = None; + let mut literal = sample_entry(); + literal.prev_hash = Some(GENESIS_HASH.to_vec()); + // v1: the documented ambiguity, pinned so it stays remembered. + assert_eq!( + compute_hash(&none).unwrap(), + compute_hash(&literal).unwrap() + ); + + let none_v2 = AuditEntry { + hash_version: HASH_ENCODING_V2, + ..none + }; + let literal_v2 = AuditEntry { + hash_version: HASH_ENCODING_V2, + ..literal + }; + assert_ne!( + compute_hash(&none_v2).unwrap(), + compute_hash(&literal_v2).unwrap() + ); + } + + #[test] + fn unknown_hash_version_is_a_hard_error() { + let mut entry = sample_entry(); + entry.hash_version = 99; + assert!(matches!( + compute_hash(&entry), + Err(AuditError::UnsupportedHashVersion { version: 99 }) + )); + } + #[test] fn canonical_json_key_order_is_stable() { let a = serde_json::json!({"z": 1, "a": 2, "m": 3}); diff --git a/crates/buzz-audit/src/lib.rs b/crates/buzz-audit/src/lib.rs index 0248a7dfd3..399fbe2088 100644 --- a/crates/buzz-audit/src/lib.rs +++ b/crates/buzz-audit/src/lib.rs @@ -31,5 +31,7 @@ pub mod service; pub use action::AuditAction; pub use entry::{AuditEntry, NewAuditEntry}; pub use error::AuditError; -pub use hash::{compute_hash, GENESIS_HASH}; +pub use hash::{ + compute_hash, CURRENT_HASH_VERSION, GENESIS_HASH, HASH_ENCODING_V1, HASH_ENCODING_V2, +}; pub use service::AuditService; diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index fa0e0fb443..ff2f5d8133 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -10,7 +10,7 @@ use crate::{ action::AuditAction, entry::{AuditEntry, NewAuditEntry}, error::AuditError, - hash::{compute_hash, to_storage_precision}, + hash::{compute_hash, to_storage_precision, CURRENT_HASH_VERSION, HASH_ENCODING_V2}, }; /// The `created_at` stamped on a new entry. @@ -121,6 +121,9 @@ impl AuditService { object_id: entry.object_id, detail: entry.detail, created_at, + // Structurally the current version: `NewAuditEntry` has no slot + // for it, so no caller can ever write the legacy v1 encoding. + hash_version: CURRENT_HASH_VERSION, }; audit_entry.hash = compute_hash(&audit_entry)?.to_vec(); @@ -130,8 +133,8 @@ impl AuditService { sqlx::query( r#" INSERT INTO audit_log - (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at, hash_version) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) "#, ) .bind(audit_entry.community_id) @@ -143,6 +146,7 @@ impl AuditService { .bind(audit_entry.object_id.as_deref()) .bind(&audit_entry.detail) .bind(audit_entry.created_at) + .bind(audit_entry.hash_version) .execute(&mut *tx) .await?; @@ -156,6 +160,18 @@ impl AuditService { /// Reads exactly that community's chain — it can never observe another /// community's entries or head. Returns `Ok(false)` if the range is empty, /// `Ok(true)` if the segment is internally consistent. + /// + /// Each row's digest is recomputed under its **stored** `hash_version`, + /// so chains written before the v2 encoding keep verifying — including + /// chains with v1 rows *after* v2 rows, which every rolling deploy + /// across the 0027 upgrade legitimately produces (pre-column pods keep + /// writing correctly-hashed v1 rows while upgraded pods stamp v2, and + /// those rows persist). A v1-after-v2 row is therefore surfaced as a + /// warning, never a failure: outside a deploy window it can indicate a + /// forged insertion reopening the legacy encoding's boundary-shift + /// collision (#4173), but a hard error would permanently brand every + /// mixed-deploy chain as tampered — and a baseline false positive makes + /// `HashMismatch` carry no signal (#2637's lesson). #[instrument(skip(self))] pub async fn verify_chain( &self, @@ -166,7 +182,7 @@ impl AuditService { let rows = sqlx::query( r#" SELECT community_id, seq, hash, prev_hash, action, actor_pubkey, - object_id, detail, created_at + object_id, detail, created_at, hash_version FROM audit_log WHERE community_id = $1 AND seq BETWEEN $2 AND $3 ORDER BY seq ASC @@ -183,10 +199,21 @@ impl AuditService { } let mut expected_prev: Option> = None; + let mut v2_seen = false; for row in &rows { let entry = row_to_audit_entry(row)?; + if entry.hash_version >= HASH_ENCODING_V2 { + v2_seen = true; + } else if v2_seen { + warn!( + seq = entry.seq, + "audit chain: v1-encoded entry follows a v2 entry; expected only from \ + pre-upgrade writers during a rolling deploy, otherwise worth investigating" + ); + } + if let Some(ref expected) = expected_prev { // The previous entry's hash must equal this entry's prev_hash. if entry.prev_hash.as_deref() != Some(expected.as_slice()) { @@ -218,7 +245,7 @@ impl AuditService { let rows = sqlx::query( r#" SELECT community_id, seq, hash, prev_hash, action, actor_pubkey, - object_id, detail, created_at + object_id, detail, created_at, hash_version FROM audit_log WHERE community_id = $1 AND seq >= $2 ORDER BY seq ASC @@ -252,6 +279,7 @@ fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 26); + assert_eq!(migrations.len(), 27); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -919,6 +919,21 @@ mod tests { assert!(heartbeat.contains("epoch")); assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); assert!(heartbeat.contains("_operator_global_tables")); + + // Audit hash-encoding version (#4173): selects, per row, the preimage + // encoding verify_chain recomputes with. Existing rows stay 1 (legacy + // unframed encoding, verify-only); the relay write path stamps 2. + // DEFAULT 1 is load-bearing for rolling deploys — pre-upgrade pods + // INSERT without naming the column — and is dropped only in a later + // cleanup once no such writers remain. + assert_eq!(migrations[26].version, 27); + let audit_hash_version = migrations[26].sql.as_str(); + assert!(audit_hash_version.contains("ALTER TABLE audit_log")); + assert!(audit_hash_version.contains("ADD COLUMN hash_version SMALLINT NOT NULL DEFAULT 1")); + assert!( + desired_schema.contains("hash_version SMALLINT NOT NULL DEFAULT 1"), + "desired-state schema must carry the audit hash_version column CI provisions from", + ); } #[test] diff --git a/migrations/0027_audit_hash_version.sql b/migrations/0027_audit_hash_version.sql new file mode 100644 index 0000000000..6e72d98746 --- /dev/null +++ b/migrations/0027_audit_hash_version.sql @@ -0,0 +1,15 @@ +-- #4173: record, per audit_log row, which hash-encoding version its digest +-- was computed with. The legacy (v1) preimage concatenated variable-length +-- fields with no framing, so two distinct entries could share a digest +-- ((object_id 'x', detail 12) vs (object_id 'x1', detail 2)) and a +-- boundary-shifting edit could survive verify_chain. New entries are hashed +-- with the length-prefixed v2 encoding; verification recomputes each row +-- under its stored version, so existing chains keep verifying unchanged. +-- +-- DEFAULT 1 is deliberate and must outlive this release: during a rolling +-- deploy, pods that predate the column still INSERT without naming it, and +-- their rows are genuinely v1-hashed. The relay's write path stamps 2 +-- explicitly; dropping the DEFAULT is a later cleanup once no pre-upgrade +-- writers remain. +ALTER TABLE audit_log + ADD COLUMN hash_version SMALLINT NOT NULL DEFAULT 1; diff --git a/schema/schema.sql b/schema/schema.sql index 3c64729367..7de9fbc50a 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -642,6 +642,11 @@ CREATE TABLE audit_log ( object_id TEXT, detail JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Hash-encoding version of `hash` (#4173): 1 = legacy unframed preimage + -- (verify-only), 2 = length-prefixed. DEFAULT 1 supports rolling deploys + -- (pre-upgrade writers INSERT without the column); the relay write path + -- stamps the current version explicitly. + hash_version SMALLINT NOT NULL DEFAULT 1, PRIMARY KEY (community_id, seq) );